Compare commits

..

2 Commits

Author SHA1 Message Date
Ahmed Ibrahim
8a07b59dc6 updates 2025-12-16 12:43:55 -08:00
Ahmed Ibrahim
23579e2a76 models 2025-12-16 12:26:46 -08:00
425 changed files with 9058 additions and 28869 deletions

View File

@@ -1,3 +1,2 @@
iTerm
iTerm2
psuedo

View File

@@ -3,4 +3,4 @@
skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts,*.jsonl,frame*.txt
check-hidden = true
ignore-regex = ^\s*"image/\S+": ".*|\b(afterAll)\b
ignore-words-list = ratatui,ser,iTerm,iterm2,iterm
ignore-words-list = ratatui,ser

View File

@@ -4,14 +4,6 @@ inputs:
target:
description: Rust compilation target triple (e.g. aarch64-apple-darwin).
required: true
sign-binaries:
description: Whether to sign and notarize the macOS binaries.
required: false
default: "true"
sign-dmg:
description: Whether to sign and notarize the macOS dmg.
required: false
default: "true"
apple-certificate:
description: Base64-encoded Apple signing certificate (P12).
required: true
@@ -115,7 +107,6 @@ runs:
echo "::add-mask::$APPLE_CODESIGN_IDENTITY"
- name: Sign macOS binaries
if: ${{ inputs.sign-binaries == 'true' }}
shell: bash
run: |
set -euo pipefail
@@ -136,7 +127,6 @@ runs:
done
- name: Notarize macOS binaries
if: ${{ inputs.sign-binaries == 'true' }}
shell: bash
env:
APPLE_NOTARIZATION_KEY_P8: ${{ inputs.apple-notarization-key-p8 }}
@@ -159,8 +149,6 @@ runs:
}
trap cleanup_notary EXIT
source "$GITHUB_ACTION_PATH/notary_helpers.sh"
notarize_binary() {
local binary="$1"
local source_path="codex-rs/target/${{ inputs.target }}/release/${binary}"
@@ -174,54 +162,32 @@ runs:
rm -f "$archive_path"
ditto -c -k --keepParent "$source_path" "$archive_path"
notarize_submission "$binary" "$archive_path" "$notary_key_path"
submission_json=$(xcrun notarytool submit "$archive_path" \
--key "$notary_key_path" \
--key-id "$APPLE_NOTARIZATION_KEY_ID" \
--issuer "$APPLE_NOTARIZATION_ISSUER_ID" \
--output-format json \
--wait)
status=$(printf '%s\n' "$submission_json" | jq -r '.status // "Unknown"')
submission_id=$(printf '%s\n' "$submission_json" | jq -r '.id // ""')
if [[ -z "$submission_id" ]]; then
echo "Failed to retrieve submission ID for $binary"
exit 1
fi
echo "::notice title=Notarization::$binary submission ${submission_id} completed with status ${status}"
if [[ "$status" != "Accepted" ]]; then
echo "Notarization failed for ${binary} (submission ${submission_id}, status ${status})"
exit 1
fi
}
notarize_binary "codex"
notarize_binary "codex-responses-api-proxy"
- name: Sign and notarize macOS dmg
if: ${{ inputs.sign-dmg == 'true' }}
shell: bash
env:
APPLE_NOTARIZATION_KEY_P8: ${{ inputs.apple-notarization-key-p8 }}
APPLE_NOTARIZATION_KEY_ID: ${{ inputs.apple-notarization-key-id }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ inputs.apple-notarization-issuer-id }}
run: |
set -euo pipefail
for var in APPLE_CODESIGN_IDENTITY APPLE_NOTARIZATION_KEY_P8 APPLE_NOTARIZATION_KEY_ID APPLE_NOTARIZATION_ISSUER_ID; do
if [[ -z "${!var:-}" ]]; then
echo "$var is required"
exit 1
fi
done
notary_key_path="${RUNNER_TEMP}/notarytool.key.p8"
echo "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$notary_key_path"
cleanup_notary() {
rm -f "$notary_key_path"
}
trap cleanup_notary EXIT
source "$GITHUB_ACTION_PATH/notary_helpers.sh"
dmg_path="codex-rs/target/${{ inputs.target }}/release/codex-${{ inputs.target }}.dmg"
if [[ ! -f "$dmg_path" ]]; then
echo "dmg $dmg_path not found"
exit 1
fi
keychain_args=()
if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" && -f "${APPLE_CODESIGN_KEYCHAIN}" ]]; then
keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}")
fi
codesign --force --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$dmg_path"
notarize_submission "codex-${{ inputs.target }}.dmg" "$dmg_path" "$notary_key_path"
xcrun stapler staple "$dmg_path"
- name: Remove signing keychain
if: ${{ always() }}
shell: bash

View File

@@ -1,46 +0,0 @@
#!/usr/bin/env bash
notarize_submission() {
local label="$1"
local path="$2"
local notary_key_path="$3"
if [[ -z "${APPLE_NOTARIZATION_KEY_ID:-}" || -z "${APPLE_NOTARIZATION_ISSUER_ID:-}" ]]; then
echo "APPLE_NOTARIZATION_KEY_ID and APPLE_NOTARIZATION_ISSUER_ID are required for notarization"
exit 1
fi
if [[ -z "$notary_key_path" || ! -f "$notary_key_path" ]]; then
echo "Notary key file $notary_key_path not found"
exit 1
fi
if [[ ! -f "$path" ]]; then
echo "Notarization payload $path not found"
exit 1
fi
local submission_json
submission_json=$(xcrun notarytool submit "$path" \
--key "$notary_key_path" \
--key-id "$APPLE_NOTARIZATION_KEY_ID" \
--issuer "$APPLE_NOTARIZATION_ISSUER_ID" \
--output-format json \
--wait)
local status submission_id
status=$(printf '%s\n' "$submission_json" | jq -r '.status // "Unknown"')
submission_id=$(printf '%s\n' "$submission_json" | jq -r '.id // ""')
if [[ -z "$submission_id" ]]; then
echo "Failed to retrieve submission ID for $label"
exit 1
fi
echo "::notice title=Notarization::$label submission ${submission_id} completed with status ${status}"
if [[ "$status" != "Accepted" ]]; then
echo "Notarization failed for ${label} (submission ${submission_id}, status ${status})"
exit 1
fi
}

BIN
.github/codex-cli-login.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

BIN
.github/codex-cli-permissions.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 818 KiB

After

Width:  |  Height:  |  Size: 3.1 MiB

BIN
.github/demo.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 MiB

View File

@@ -55,30 +55,6 @@
"path": "codex-responses-api-proxy.exe"
}
}
},
"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"
}
}
}
}
}

View File

@@ -20,7 +20,7 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Run cargo-deny
uses: EmbarkStudios/cargo-deny-action@v2
uses: EmbarkStudios/cargo-deny-action@v1
with:
rust-version: stable
manifest-path: ./codex-rs/Cargo.toml

View File

@@ -36,8 +36,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Use a rust-release version that includes all native binaries.
CODEX_VERSION=0.74.0
CODEX_VERSION=0.40.0
OUTPUT_DIR="${RUNNER_TEMP}"
python3 ./scripts/stage_npm_packages.py \
--release-version "$CODEX_VERSION" \

View File

@@ -12,8 +12,6 @@ permissions:
jobs:
close-stale-contributor-prs:
# Prevent scheduled runs on forks
if: github.repository == 'openai/codex'
runs-on: ubuntu-latest
steps:
- name: Close inactive PRs from contributors

View File

@@ -9,8 +9,7 @@ on:
jobs:
gather-duplicates:
name: Identify potential duplicates
# 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'))
if: ${{ github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate') }}
runs-on: ubuntu-latest
permissions:
contents: read

View File

@@ -9,8 +9,7 @@ on:
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'))
if: ${{ github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label') }}
runs-on: ubuntu-latest
permissions:
contents: read

View File

@@ -28,11 +28,9 @@ jobs:
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
BASE_SHA='${{ github.event.pull_request.base.sha }}'
HEAD_SHA='${{ github.event.pull_request.head.sha }}'
echo "Base SHA: $BASE_SHA"
echo "Head SHA: $HEAD_SHA"
# List files changed between base and PR head
mapfile -t files < <(git diff --name-only --no-renames "$BASE_SHA" "$HEAD_SHA")
# List files changed between base and current HEAD (merge-base aware)
mapfile -t files < <(git diff --name-only --no-renames "$BASE_SHA"...HEAD)
else
# On push / manual runs, default to running everything
files=("codex-rs/force" ".github/force")

View File

@@ -1,53 +0,0 @@
name: rust-release-prepare
on:
workflow_dispatch:
schedule:
- cron: "0 */4 * * *"
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
jobs:
prepare:
# Prevent scheduled runs on forks (no secrets, wastes Actions minutes)
if: github.repository == 'openai/codex'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
- name: Update models.json
env:
OPENAI_API_KEY: ${{ secrets.CODEX_OPENAI_API_KEY }}
run: |
set -euo pipefail
client_version="99.99.99"
terminal_info="github-actions"
user_agent="codex_cli_rs/99.99.99 (Linux $(uname -r); $(uname -m)) ${terminal_info}"
base_url="${OPENAI_BASE_URL:-https://chatgpt.com/backend-api/codex}"
headers=(
-H "Authorization: Bearer ${OPENAI_API_KEY}"
-H "User-Agent: ${user_agent}"
)
url="${base_url%/}/models?client_version=${client_version}"
curl --http1.1 --fail --show-error --location "${headers[@]}" "${url}" | jq '.' > codex-rs/core/models.json
- name: Open pull request (if changed)
uses: peter-evans/create-pull-request@v8
with:
commit-message: "Update models.json"
title: "Update models.json"
body: "Automated update of models.json."
branch: "bot/update-models-json"
reviewers: "pakrym-oai,aibrahim-oai"
delete-branch: true

View File

@@ -128,72 +128,11 @@ jobs:
account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }}
- if: ${{ runner.os == 'macOS' }}
name: MacOS code signing (binaries)
- if: ${{ matrix.runner == 'macos-15-xlarge' }}
name: MacOS code signing
uses: ./.github/actions/macos-code-sign
with:
target: ${{ matrix.target }}
sign-binaries: "true"
sign-dmg: "false"
apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }}
apple-certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
apple-notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
- if: ${{ runner.os == 'macOS' }}
name: Build macOS dmg
shell: bash
run: |
set -euo pipefail
target="${{ matrix.target }}"
release_dir="target/${target}/release"
dmg_root="${RUNNER_TEMP}/codex-dmg-root"
volname="Codex (${target})"
dmg_path="${release_dir}/codex-${target}.dmg"
# The previous "MacOS code signing (binaries)" step signs + notarizes the
# built artifacts in `${release_dir}`. This step packages *those same*
# signed binaries into a dmg.
codex_binary_path="${release_dir}/codex"
proxy_binary_path="${release_dir}/codex-responses-api-proxy"
rm -rf "$dmg_root"
mkdir -p "$dmg_root"
if [[ ! -f "$codex_binary_path" ]]; then
echo "Binary $codex_binary_path not found"
exit 1
fi
if [[ ! -f "$proxy_binary_path" ]]; then
echo "Binary $proxy_binary_path not found"
exit 1
fi
ditto "$codex_binary_path" "${dmg_root}/codex"
ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy"
rm -f "$dmg_path"
hdiutil create \
-volname "$volname" \
-srcfolder "$dmg_root" \
-format UDZO \
-ov \
"$dmg_path"
if [[ ! -f "$dmg_path" ]]; then
echo "dmg $dmg_path not found after build"
exit 1
fi
- if: ${{ runner.os == 'macOS' }}
name: MacOS code signing (dmg)
uses: ./.github/actions/macos-code-sign
with:
target: ${{ matrix.target }}
sign-binaries: "false"
sign-dmg: "true"
apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }}
apple-certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
apple-notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
@@ -221,10 +160,6 @@ jobs:
cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore"
fi
if [[ "${{ matrix.target }}" == *apple-darwin ]]; then
cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg"
fi
- if: ${{ matrix.runner == 'windows-11-arm' }}
name: Install zstd
shell: powershell
@@ -259,7 +194,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" == *.zip ]]; then
continue
fi

5
.gitignore vendored
View File

@@ -85,8 +85,3 @@ CHANGELOG.ignore.md
# nix related
.direnv
.envrc
# Python bytecode files
__pycache__/
*.pyc

View File

@@ -75,13 +75,6 @@ If you dont have the tool:
- Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already.
- Prefer deep equals comparisons whenever possible. Perform `assert_eq!()` on entire objects, rather than individual fields.
- Avoid mutating process environment in tests; prefer passing environment-derived flags or dependencies from above.
### Spawning workspace binaries in tests (Cargo vs Buck2)
- Prefer `codex_utils_cargo_bin::cargo_bin("...")` over `assert_cmd::Command::cargo_bin(...)` or `escargot` when tests need to spawn first-party binaries.
- Under Buck2, `CARGO_BIN_EXE_*` may be project-relative (e.g. `buck-out/...`), which breaks if a test changes its working directory. `codex_utils_cargo_bin::cargo_bin` resolves to an absolute path first.
- When locating fixture files under Buck2, avoid `env!("CARGO_MANIFEST_DIR")` (Buck codegen sets it to `"."`). Prefer deriving paths from `codex_utils_cargo_bin::buck_project_root()` when needed.
### Integration tests (core)

View File

@@ -1,11 +1,13 @@
<p align="center"><code>npm i -g @openai/codex</code><br />or <code>brew install --cask codex</code></p>
<p align="center"><strong>Codex CLI</strong> is a coding agent from OpenAI that runs locally on your computer.
</br>
</br>If you want Codex in your code editor (VS Code, Cursor, Windsurf), <a href="https://developers.openai.com/codex/ide">install in your IDE</a>
</br>If you are looking for the <em>cloud-based agent</em> from OpenAI, <strong>Codex Web</strong>, go to <a href="https://chatgpt.com/codex">chatgpt.com/codex</a></p>
<p align="center">
<img src="./.github/codex-cli-splash.png" alt="Codex CLI splash" width="80%" />
</p>
</br>
If you want Codex in your code editor (VS Code, Cursor, Windsurf), <a href="https://developers.openai.com/codex/ide">install in your IDE.</a>
</br>If you are looking for the <em>cloud-based agent</em> from OpenAI, <strong>Codex Web</strong>, go to <a href="https://chatgpt.com/codex">chatgpt.com/codex</a>.</p>
</p>
---
@@ -13,19 +15,25 @@ If you want Codex in your code editor (VS Code, Cursor, Windsurf), <a href="http
### Installing and running Codex CLI
Install globally with your preferred package manager:
Install globally with your preferred package manager. If you use npm:
```shell
# Install using npm
npm install -g @openai/codex
```
Alternatively, if you use Homebrew:
```shell
# Install using Homebrew
brew install --cask codex
```
Then simply run `codex` to get started.
Then simply run `codex` to get started:
```shell
codex
```
If you're running into upgrade issues with Homebrew, see the [FAQ entry on brew upgrade codex](./docs/faq.md#brew-upgrade-codex-isnt-upgrading-me).
<details>
<summary>You can also go to the <a href="https://github.com/openai/codex/releases/latest">latest GitHub Release</a> and download the appropriate binary for your platform.</summary>
@@ -45,15 +53,60 @@ Each archive contains a single entry with the platform baked into the name (e.g.
### Using Codex with your ChatGPT plan
<p align="center">
<img src="./.github/codex-cli-login.png" alt="Codex CLI login" width="80%" />
</p>
Run `codex` and select **Sign in with ChatGPT**. We recommend signing into your ChatGPT account to use Codex as part of your Plus, Pro, Team, Edu, or Enterprise plan. [Learn more about what's included in your ChatGPT plan](https://help.openai.com/en/articles/11369540-codex-in-chatgpt).
You can also use Codex with an API key, but this requires [additional setup](https://developers.openai.com/codex/auth#sign-in-with-an-api-key).
You can also use Codex with an API key, but this requires [additional setup](./docs/authentication.md#usage-based-billing-alternative-use-an-openai-api-key). If you previously used an API key for usage-based billing, see the [migration steps](./docs/authentication.md#migrating-from-usage-based-billing-api-key). If you're having trouble with login, please comment on [this issue](https://github.com/openai/codex/issues/1243).
## Docs
### Model Context Protocol (MCP)
- [**Codex Documentation**](https://developers.openai.com/codex)
Codex can access MCP servers. To configure them, refer to the [config docs](./docs/config.md#mcp_servers).
### Configuration
Codex CLI supports a rich set of configuration options, with preferences stored in `~/.codex/config.toml`. For full configuration options, see [Configuration](./docs/config.md).
### Execpolicy
See the [Execpolicy quickstart](./docs/execpolicy.md) to set up rules that govern what commands Codex can execute.
### Docs & FAQ
- [**Getting started**](./docs/getting-started.md)
- [CLI usage](./docs/getting-started.md#cli-usage)
- [Slash Commands](./docs/slash_commands.md)
- [Running with a prompt as input](./docs/getting-started.md#running-with-a-prompt-as-input)
- [Example prompts](./docs/getting-started.md#example-prompts)
- [Custom prompts](./docs/prompts.md)
- [Memory with AGENTS.md](./docs/getting-started.md#memory-with-agentsmd)
- [**Configuration**](./docs/config.md)
- [Example config](./docs/example-config.md)
- [**Sandbox & approvals**](./docs/sandbox.md)
- [**Execpolicy quickstart**](./docs/execpolicy.md)
- [**Authentication**](./docs/authentication.md)
- [Auth methods](./docs/authentication.md#forcing-a-specific-auth-method-advanced)
- [Login on a "Headless" machine](./docs/authentication.md#connecting-on-a-headless-machine)
- **Automating Codex**
- [GitHub Action](https://github.com/openai/codex-action)
- [TypeScript SDK](./sdk/typescript/README.md)
- [Non-interactive mode (`codex exec`)](./docs/exec.md)
- [**Advanced**](./docs/advanced.md)
- [Tracing / verbose logging](./docs/advanced.md#tracing--verbose-logging)
- [Model Context Protocol (MCP)](./docs/advanced.md#model-context-protocol-mcp)
- [**Zero data retention (ZDR)**](./docs/zdr.md)
- [**Contributing**](./docs/contributing.md)
- [**Installing & building**](./docs/install.md)
- [**Install & build**](./docs/install.md)
- [System Requirements](./docs/install.md#system-requirements)
- [DotSlash](./docs/install.md#dotslash)
- [Build from source](./docs/install.md#build-from-source)
- [**FAQ**](./docs/faq.md)
- [**Open source fund**](./docs/open-source-fund.md)
---
## License
This repository is licensed under the [Apache-2.0 License](LICENSE).

View File

@@ -20,14 +20,9 @@ PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = {
"codex-responses-api-proxy": ["codex-responses-api-proxy"],
"codex-sdk": ["codex"],
}
WINDOWS_ONLY_COMPONENTS: dict[str, list[str]] = {
"codex": ["codex-windows-sandbox-setup", "codex-command-runner"],
}
COMPONENT_DEST_DIR: dict[str, str] = {
"codex": "codex",
"codex-responses-api-proxy": "codex-responses-api-proxy",
"codex-windows-sandbox-setup": "codex",
"codex-command-runner": "codex",
"rg": "path",
}
@@ -108,7 +103,7 @@ def main() -> int:
"pointing to a directory containing pre-installed binaries."
)
copy_native_binaries(vendor_src, staging_dir, package, native_components)
copy_native_binaries(vendor_src, staging_dir, native_components)
if release_version:
staging_dir_str = str(staging_dir)
@@ -237,12 +232,7 @@ def stage_codex_sdk_sources(staging_dir: Path) -> None:
shutil.copy2(license_src, staging_dir / "LICENSE")
def copy_native_binaries(
vendor_src: Path,
staging_dir: Path,
package: str,
components: list[str],
) -> None:
def copy_native_binaries(vendor_src: Path, staging_dir: Path, components: list[str]) -> None:
vendor_src = vendor_src.resolve()
if not vendor_src.exists():
raise RuntimeError(f"Vendor source directory not found: {vendor_src}")
@@ -260,9 +250,6 @@ def copy_native_binaries(
if not target_dir.is_dir():
continue
if "windows" in target_dir.name:
components_set.update(WINDOWS_ONLY_COMPONENTS.get(package, []))
dest_target_dir = vendor_dest / target_dir.name
dest_target_dir.mkdir(parents=True, exist_ok=True)

View File

@@ -2,7 +2,6 @@
"""Install Codex native binaries (Rust CLI plus ripgrep helpers)."""
import argparse
from contextlib import contextmanager
import json
import os
import shutil
@@ -13,7 +12,6 @@ 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
@@ -38,11 +36,8 @@ class BinaryComponent:
artifact_prefix: str # matches the artifact filename prefix (e.g. codex-<target>.zst)
dest_dir: str # directory under vendor/<target>/ 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)
BINARY_COMPONENTS = {
"codex": BinaryComponent(
artifact_prefix="codex",
@@ -54,18 +49,6 @@ BINARY_COMPONENTS = {
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]] = [
@@ -79,45 +62,6 @@ RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [
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.")
@@ -135,8 +79,7 @@ def parse_args() -> argparse.Namespace:
choices=tuple(list(BINARY_COMPONENTS) + ["rg"]),
help=(
"Limit installation to the specified components."
" May be repeated. Defaults to codex, codex-windows-sandbox-setup,"
" codex-command-runner, and rg."
" May be repeated. Defaults to 'codex' and 'rg'."
),
)
parser.add_argument(
@@ -158,12 +101,7 @@ def main() -> int:
vendor_dir = codex_cli_root / VENDOR_DIR_NAME
vendor_dir.mkdir(parents=True, exist_ok=True)
components = args.components or [
"codex",
"codex-windows-sandbox-setup",
"codex-command-runner",
"rg",
]
components = args.components or ["codex", "rg"]
workflow_url = (args.workflow_url or DEFAULT_WORKFLOW_URL).strip()
if not workflow_url:
@@ -172,20 +110,19 @@ def main() -> int:
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],
)
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_TARGETS,
[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("Fetching ripgrep binaries...")
fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST)
print(f"Installed native dependencies into {vendor_dir}")
return 0
@@ -246,14 +183,7 @@ def fetch_rg(
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
results[target] = future.result()
print(f" installed ripgrep for {target}")
return [results[target] for target in targets]
@@ -276,19 +206,23 @@ def _download_artifacts(workflow_id: str, dest_dir: Path) -> None:
def install_binary_components(
artifacts_dir: Path,
vendor_dir: Path,
selected_components: Sequence[BinaryComponent],
targets: Iterable[str],
component_names: Sequence[str],
) -> None:
selected_components = [BINARY_COMPONENTS[name] for name in component_names if name in BINARY_COMPONENTS]
if not selected_components:
return
for component in selected_components:
component_targets = list(component.targets or BINARY_TARGETS)
targets = list(targets)
if not targets:
return
for component in selected_components:
print(
f"Installing {component.binary_basename} binaries for targets: "
+ ", ".join(component_targets)
+ ", ".join(targets)
)
max_workers = min(len(component_targets), max(1, (os.cpu_count() or 1)))
max_workers = min(len(targets), max(1, (os.cpu_count() or 1)))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
@@ -298,7 +232,7 @@ def install_binary_components(
target,
component,
): target
for target in component_targets
for target in targets
}
for future in as_completed(futures):
installed_path = future.result()
@@ -351,8 +285,6 @@ def _fetch_single_rg(
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)
@@ -365,32 +297,10 @@ def _fetch_single_rg(
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
_download_file(url, download_path)
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
extract_archive(download_path, archive_format, archive_member, dest)
if not is_windows:
dest.chmod(0o755)
@@ -400,9 +310,7 @@ def _fetch_single_rg(
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:
with urlopen(url) as response, open(dest, "wb") as out:
shutil.copyfileobj(response, out)

474
codex-rs/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -36,7 +36,6 @@ members = [
"tui",
"tui2",
"utils/absolute-path",
"utils/cargo-bin",
"utils/git",
"utils/cache",
"utils/image",
@@ -94,7 +93,6 @@ codex-tui = { path = "tui" }
codex-tui2 = { path = "tui2" }
codex-utils-absolute-path = { path = "utils/absolute-path" }
codex-utils-cache = { path = "utils/cache" }
codex-utils-cargo-bin = { path = "utils/cargo-bin" }
codex-utils-image = { path = "utils/image" }
codex-utils-json-to-toml = { path = "utils/json-to-toml" }
codex-utils-pty = { path = "utils/pty" }
@@ -143,12 +141,11 @@ icu_locale_core = "2.1"
icu_provider = { version = "2.1", features = ["sync"] }
ignore = "0.4.23"
image = { version = "^0.25.9", default-features = false }
include_dir = "0.7.4"
indexmap = "2.12.0"
insta = "1.46.0"
insta = "1.44.3"
itertools = "0.14.0"
keyring = { version = "3.6", default-features = false }
landlock = "0.4.4"
landlock = "0.4.1"
lazy_static = "1"
libc = "0.2.177"
log = "0.4"
@@ -160,12 +157,12 @@ notify = "8.2.0"
nucleo-matcher = "0.3.1"
once_cell = "1.20.2"
openssl-sys = "*"
opentelemetry = "0.31.0"
opentelemetry-appender-tracing = "0.31.0"
opentelemetry-otlp = "0.31.0"
opentelemetry-semantic-conventions = "0.31.0"
opentelemetry_sdk = "0.31.0"
tracing-opentelemetry = "0.32.0"
opentelemetry = "0.30.0"
opentelemetry-appender-tracing = "0.30.0"
opentelemetry-otlp = "0.30.0"
opentelemetry-semantic-conventions = "0.30.0"
opentelemetry_sdk = "0.30.0"
tracing-opentelemetry = "0.31.0"
os_info = "3.12.0"
owo-colors = "4.2.0"
path-absolutize = "3.1.1"
@@ -176,12 +173,11 @@ pretty_assertions = "1.4.1"
pulldown-cmark = "0.10"
rand = "0.9"
ratatui = "0.29.0"
ratatui-core = "0.1.0"
ratatui-macros = "0.6.0"
regex = "1.12.2"
regex-lite = "0.1.8"
regex-lite = "0.1.7"
reqwest = "0.12"
rmcp = { version = "0.12.0", default-features = false }
rmcp = { version = "0.10.0", default-features = false }
schemars = "0.8.22"
seccompiler = "0.5.0"
sentry = "0.46.0"
@@ -201,26 +197,26 @@ strum_macros = "0.27.2"
supports-color = "3.0.2"
sys-locale = "0.3.2"
tempfile = "3.23.0"
test-log = "0.2.19"
test-log = "0.2.18"
textwrap = "0.16.2"
thiserror = "2.0.17"
time = "0.3"
tiny_http = "0.12"
tokio = "1"
tokio-stream = "0.1.18"
tokio-stream = "0.1.17"
tokio-test = "0.4"
tokio-util = "0.7.16"
toml = "0.9.5"
toml_edit = "0.24.0"
toml_edit = "0.23.5"
tonic = "0.13.1"
tracing = "0.1.43"
tracing-appender = "0.2.3"
tracing-subscriber = "0.3.22"
tracing-subscriber = "0.3.20"
tracing-test = "0.2.5"
tree-sitter = "0.25.10"
tree-sitter-bash = "0.25"
tree-sitter-highlight = "0.25.10"
ts-rs = "11"
tui-scrollbar = "0.2.1"
uds_windows = "1.1.0"
unicode-segmentation = "1.12.0"
unicode-width = "0.2"

View File

@@ -15,8 +15,8 @@ You can also install via Homebrew (`brew install --cask codex`) or download a pl
## Documentation quickstart
- First run with Codex? Start with [`docs/getting-started.md`](../docs/getting-started.md) (links to the walkthrough for prompts, keyboard shortcuts, and session management).
- Want deeper control? See [`docs/config.md`](../docs/config.md) and [`docs/install.md`](../docs/install.md).
- First run with Codex? Follow the walkthrough in [`docs/getting-started.md`](../docs/getting-started.md) for prompts, keyboard shortcuts, and session management.
- Already shipping with Codex and want deeper control? Jump to [`docs/advanced.md`](../docs/advanced.md) and the configuration reference at [`docs/config.md`](../docs/config.md).
## What's new in the Rust CLI
@@ -30,7 +30,7 @@ Codex supports a rich set of configuration options. Note that the Rust CLI uses
#### MCP client
Codex CLI functions as an MCP client that allows the Codex CLI and IDE extension to connect to MCP servers on startup. See the [`configuration documentation`](../docs/config.md#connecting-to-mcp-servers) for details.
Codex CLI functions as an MCP client that allows the Codex CLI and IDE extension to connect to MCP servers on startup. See the [`configuration documentation`](../docs/config.md#mcp_servers) for details.
#### MCP server (experimental)

View File

@@ -144,9 +144,9 @@ client_request_definitions! {
response: v2::McpServerOauthLoginResponse,
},
McpServerStatusList => "mcpServerStatus/list" {
params: v2::ListMcpServerStatusParams,
response: v2::ListMcpServerStatusResponse,
McpServersList => "mcpServers/list" {
params: v2::ListMcpServersParams,
response: v2::ListMcpServersResponse,
},
LoginAccount => "account/login/start" {
@@ -525,8 +525,6 @@ server_notification_definitions! {
TurnPlanUpdated => "turn/plan/updated" (v2::TurnPlanUpdatedNotification),
ItemStarted => "item/started" (v2::ItemStartedNotification),
ItemCompleted => "item/completed" (v2::ItemCompletedNotification),
/// This event is internal-only. Used by Codex Cloud.
RawResponseItemCompleted => "rawResponseItem/completed" (v2::RawResponseItemCompletedNotification),
AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification),
CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification),
TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification),
@@ -534,12 +532,12 @@ server_notification_definitions! {
McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification),
McpServerOauthLoginCompleted => "mcpServer/oauthLogin/completed" (v2::McpServerOauthLoginCompletedNotification),
AccountUpdated => "account/updated" (v2::AccountUpdatedNotification),
ModelPresetsUpdated => "model/presets/updated" (v2::ModelPresetsUpdatedNotification),
AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification),
ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification),
ReasoningSummaryPartAdded => "item/reasoning/summaryPartAdded" (v2::ReasoningSummaryPartAddedNotification),
ReasoningTextDelta => "item/reasoning/textDelta" (v2::ReasoningTextDeltaNotification),
ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification),
DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification),
/// Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.
WindowsWorldWritableWarning => "windows/worldWritableWarning" (v2::WindowsWorldWritableWarningNotification),

View File

@@ -384,8 +384,6 @@ pub struct SendUserTurnParams {
pub model: String,
pub effort: Option<ReasoningEffort>,
pub summary: ReasoningSummary,
/// Optional JSON Schema used to constrain the final assistant message for this turn.
pub output_schema: Option<serde_json::Value>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]

View File

@@ -18,7 +18,6 @@ use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus;
use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo;
use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
use codex_protocol::protocol::SessionSource as CoreSessionSource;
@@ -209,87 +208,14 @@ v2_enum_from_core!(
}
);
// TODO(mbolin): Support in-repo layer.
#[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 ConfigLayerSource {
/// Managed preferences layer delivered by MDM (macOS only).
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Mdm {
domain: String,
key: String,
},
/// Managed config layer from a file (usually `managed_config.toml`).
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
System {
/// This is the path to the system config.toml file, though it is not
/// guaranteed to exist.
file: AbsolutePathBuf,
},
/// 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
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
User {
/// This is the path to the user's config.toml file, though it is not
/// guaranteed to exist.
file: AbsolutePathBuf,
},
/// Path to a .codex/ folder within a project. There could be multiple of
/// these between `cwd` and the project/repo root.
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Project {
dot_codex_folder: AbsolutePathBuf,
},
/// Session-layer overrides supplied via `-c`/`--config`.
pub enum ConfigLayerName {
Mdm,
System,
SessionFlags,
/// `managed_config.toml` was designed to be a config that was loaded
/// as the last layer on top of everything else. This scheme did not quite
/// work out as intended, but we keep this variant as a "best effort" while
/// we phase out `managed_config.toml` in favor of `requirements.toml`.
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
LegacyManagedConfigTomlFromFile {
file: AbsolutePathBuf,
},
LegacyManagedConfigTomlFromMdm,
}
impl ConfigLayerSource {
/// A settings from a layer with a higher precedence will override a setting
/// from a layer with a lower precedence.
pub fn precedence(&self) -> i16 {
match self {
ConfigLayerSource::Mdm { .. } => 0,
ConfigLayerSource::System { .. } => 10,
ConfigLayerSource::User { .. } => 20,
ConfigLayerSource::Project { .. } => 25,
ConfigLayerSource::SessionFlags => 30,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40,
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50,
}
}
}
/// Compares [ConfigLayerSource] by precedence, so `A < B` means settings from
/// layer `A` will be overridden by settings from layer `B`.
impl PartialOrd for ConfigLayerSource {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.precedence().cmp(&other.precedence()))
}
User,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
@@ -362,7 +288,8 @@ pub struct Config {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ConfigLayerMetadata {
pub name: ConfigLayerSource,
pub name: ConfigLayerName,
pub source: String,
pub version: String,
}
@@ -370,7 +297,8 @@ pub struct ConfigLayerMetadata {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ConfigLayer {
pub name: ConfigLayerSource,
pub name: ConfigLayerName,
pub source: String,
pub version: String,
pub config: JsonValue,
}
@@ -407,7 +335,7 @@ pub struct ConfigWriteResponse {
pub status: WriteStatus,
pub version: String,
/// Canonical path to the config file that was written.
pub file_path: AbsolutePathBuf,
pub file_path: String,
pub overridden_metadata: Option<OverriddenMetadata>,
}
@@ -420,7 +348,6 @@ pub enum ConfigWriteErrorCode {
ConfigValidationError,
ConfigPathNotFound,
ConfigSchemaUnknownKey,
UserLayerNotFound,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -486,15 +413,6 @@ pub enum ApprovalDecision {
Cancel,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum NetworkAccess {
#[default]
Restricted,
Enabled,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
@@ -504,12 +422,6 @@ pub enum SandboxPolicy {
ReadOnly,
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
ExternalSandbox {
#[serde(default)]
network_access: NetworkAccess,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
WorkspaceWrite {
#[serde(default)]
writable_roots: Vec<AbsolutePathBuf>,
@@ -529,14 +441,6 @@ impl SandboxPolicy {
codex_protocol::protocol::SandboxPolicy::DangerFullAccess
}
SandboxPolicy::ReadOnly => codex_protocol::protocol::SandboxPolicy::ReadOnly,
SandboxPolicy::ExternalSandbox { network_access } => {
codex_protocol::protocol::SandboxPolicy::ExternalSandbox {
network_access: match network_access {
NetworkAccess::Restricted => CoreNetworkAccess::Restricted,
NetworkAccess::Enabled => CoreNetworkAccess::Enabled,
},
}
}
SandboxPolicy::WorkspaceWrite {
writable_roots,
network_access,
@@ -559,14 +463,6 @@ impl From<codex_protocol::protocol::SandboxPolicy> for SandboxPolicy {
SandboxPolicy::DangerFullAccess
}
codex_protocol::protocol::SandboxPolicy::ReadOnly => SandboxPolicy::ReadOnly,
codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access } => {
SandboxPolicy::ExternalSandbox {
network_access: match network_access {
CoreNetworkAccess::Restricted => NetworkAccess::Restricted,
CoreNetworkAccess::Enabled => NetworkAccess::Enabled,
},
}
}
codex_protocol::protocol::SandboxPolicy::WorkspaceWrite {
writable_roots,
network_access,
@@ -855,17 +751,12 @@ pub struct ReasoningEffortOption {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ModelListResponse {
pub data: Vec<Model>,
/// Opaque cursor to pass to the next call to continue after the last item.
/// If None, there are no more items to return.
pub next_cursor: Option<String>,
}
pub struct ModelListResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ListMcpServerStatusParams {
pub struct ListMcpServersParams {
/// Opaque pagination cursor returned by a previous call.
pub cursor: Option<String>,
/// Optional page size; defaults to a server-defined value.
@@ -875,7 +766,7 @@ pub struct ListMcpServerStatusParams {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct McpServerStatus {
pub struct McpServer {
pub name: String,
pub tools: std::collections::HashMap<String, McpTool>,
pub resources: Vec<McpResource>,
@@ -886,8 +777,8 @@ pub struct McpServerStatus {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ListMcpServerStatusResponse {
pub data: Vec<McpServerStatus>,
pub struct ListMcpServersResponse {
pub data: Vec<McpServer>,
/// Opaque cursor to pass to the next call to continue after the last item.
/// If None, there are no more items to return.
pub next_cursor: Option<String>,
@@ -964,12 +855,6 @@ pub struct ThreadStartParams {
pub config: Option<HashMap<String, JsonValue>>,
pub base_instructions: Option<String>,
pub developer_instructions: Option<String>,
/// If true, opt into emitting raw response items on the event stream.
///
/// This is for internal use only (e.g. Codex Cloud).
/// (TODO): Figure out a better way to categorize internal / experimental events & protocols.
#[serde(default)]
pub experimental_raw_events: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -1075,10 +960,6 @@ pub struct SkillsListParams {
/// When empty, defaults to the current session working directory.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cwds: Vec<PathBuf>,
/// When true, bypass the skills cache and re-scan skills from disk.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub force_reload: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -1095,8 +976,6 @@ pub struct SkillsListResponse {
pub enum SkillScope {
User,
Repo,
System,
Admin,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -1105,9 +984,6 @@ pub enum SkillScope {
pub struct SkillMetadata {
pub name: String,
pub description: String,
#[ts(optional)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub short_description: Option<String>,
pub path: PathBuf,
pub scope: SkillScope,
}
@@ -1134,7 +1010,6 @@ impl From<CoreSkillMetadata> for SkillMetadata {
Self {
name: value.name,
description: value.description,
short_description: value.short_description,
path: value.path,
scope: value.scope.into(),
}
@@ -1146,8 +1021,6 @@ impl From<CoreSkillScope> for SkillScope {
match value {
CoreSkillScope::User => Self::User,
CoreSkillScope::Repo => Self::Repo,
CoreSkillScope::System => Self::System,
CoreSkillScope::Admin => Self::Admin,
}
}
}
@@ -1196,6 +1069,13 @@ pub struct AccountUpdatedNotification {
pub auth_mode: Option<AuthMode>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ModelPresetsUpdatedNotification {
pub models: Vec<Model>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1274,8 +1154,6 @@ pub struct Turn {
pub struct TurnError {
pub message: String,
pub codex_error_info: Option<CodexErrorInfo>,
#[serde(default)]
pub additional_details: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -1319,8 +1197,6 @@ pub struct TurnStartParams {
pub effort: Option<ReasoningEffort>,
/// Override the reasoning summary for this turn and subsequent turns.
pub summary: Option<ReasoningSummary>,
/// Optional JSON Schema used to constrain the final assistant message for this turn.
pub output_schema: Option<JsonValue>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -1707,15 +1583,6 @@ pub struct ItemCompletedNotification {
pub turn_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RawResponseItemCompletedNotification {
pub thread_id: String,
pub turn_id: String,
pub item: ResponseItem,
}
// Item-specific progress notifications
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
@@ -1950,16 +1817,6 @@ pub struct AccountLoginCompletedNotification {
pub error: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DeprecationNoticeNotification {
/// Concise summary of what is deprecated.
pub summary: String,
/// Optional extra guidance, such as migration steps or rationale.
pub details: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1969,30 +1826,11 @@ mod tests {
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::items::WebSearchItem;
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::user_input::UserInput as CoreUserInput;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::PathBuf;
#[test]
fn sandbox_policy_round_trips_external_sandbox_network_access() {
let v2_policy = SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
};
let core_policy = v2_policy.to_core();
assert_eq!(
core_policy,
codex_protocol::protocol::SandboxPolicy::ExternalSandbox {
network_access: CoreNetworkAccess::Enabled,
}
);
let back_to_v2 = SandboxPolicy::from(core_policy);
assert_eq!(back_to_v2, v2_policy);
}
#[test]
fn core_turn_item_into_thread_item_converts_supported_variants() {
let user_item = TurnItem::UserMessage(UserMessageItem {
@@ -2077,30 +1915,6 @@ mod tests {
);
}
#[test]
fn skills_list_params_serialization_uses_force_reload() {
assert_eq!(
serde_json::to_value(SkillsListParams {
cwds: Vec::new(),
force_reload: false,
})
.unwrap(),
json!({}),
);
assert_eq!(
serde_json::to_value(SkillsListParams {
cwds: vec![PathBuf::from("/repo")],
force_reload: true,
})
.unwrap(),
json!({
"cwds": ["/repo"],
"forceReload": true,
}),
);
}
#[test]
fn codex_error_info_serializes_http_status_code_in_camel_case() {
let value = CodexErrorInfo::ResponseTooManyFailedAttempts {

View File

@@ -13,7 +13,6 @@ use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use clap::ArgAction;
use clap::Parser;
use clap::Subcommand;
use codex_app_server_protocol::AddConversationListenerParams;
@@ -66,19 +65,6 @@ struct Cli {
#[arg(long, env = "CODEX_BIN", default_value = "codex")]
codex_bin: String,
/// Forwarded to the `codex` CLI as `--config key=value`. Repeatable.
///
/// Example:
/// `--config 'model_providers.mock.base_url="http://localhost:4010/v2"'`
#[arg(
short = 'c',
long = "config",
value_name = "key=value",
action = ArgAction::Append,
global = true
)]
config_overrides: Vec<String>,
#[command(subcommand)]
command: CliCommand,
}
@@ -130,42 +116,29 @@ enum CliCommand {
}
fn main() -> Result<()> {
let Cli {
codex_bin,
config_overrides,
command,
} = Cli::parse();
let Cli { codex_bin, command } = Cli::parse();
match command {
CliCommand::SendMessage { user_message } => {
send_message(&codex_bin, &config_overrides, user_message)
}
CliCommand::SendMessageV2 { user_message } => {
send_message_v2(&codex_bin, &config_overrides, user_message)
}
CliCommand::SendMessage { user_message } => send_message(codex_bin, user_message),
CliCommand::SendMessageV2 { user_message } => send_message_v2(codex_bin, user_message),
CliCommand::TriggerCmdApproval { user_message } => {
trigger_cmd_approval(&codex_bin, &config_overrides, user_message)
trigger_cmd_approval(codex_bin, user_message)
}
CliCommand::TriggerPatchApproval { user_message } => {
trigger_patch_approval(&codex_bin, &config_overrides, user_message)
trigger_patch_approval(codex_bin, user_message)
}
CliCommand::NoTriggerCmdApproval => no_trigger_cmd_approval(&codex_bin, &config_overrides),
CliCommand::NoTriggerCmdApproval => no_trigger_cmd_approval(codex_bin),
CliCommand::SendFollowUpV2 {
first_message,
follow_up_message,
} => send_follow_up_v2(
&codex_bin,
&config_overrides,
first_message,
follow_up_message,
),
CliCommand::TestLogin => test_login(&codex_bin, &config_overrides),
CliCommand::GetAccountRateLimits => get_account_rate_limits(&codex_bin, &config_overrides),
} => send_follow_up_v2(codex_bin, first_message, follow_up_message),
CliCommand::TestLogin => test_login(codex_bin),
CliCommand::GetAccountRateLimits => get_account_rate_limits(codex_bin),
}
}
fn send_message(codex_bin: &str, config_overrides: &[String], user_message: String) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
fn send_message(codex_bin: String, user_message: String) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -186,61 +159,46 @@ fn send_message(codex_bin: &str, config_overrides: &[String], user_message: Stri
Ok(())
}
fn send_message_v2(
codex_bin: &str,
config_overrides: &[String],
user_message: String,
) -> Result<()> {
send_message_v2_with_policies(codex_bin, config_overrides, user_message, None, None)
fn send_message_v2(codex_bin: String, user_message: String) -> Result<()> {
send_message_v2_with_policies(codex_bin, user_message, None, None)
}
fn trigger_cmd_approval(
codex_bin: &str,
config_overrides: &[String],
user_message: Option<String>,
) -> Result<()> {
fn trigger_cmd_approval(codex_bin: String, user_message: Option<String>) -> Result<()> {
let default_prompt =
"Run `touch /tmp/should-trigger-approval` so I can confirm the file exists.";
let message = user_message.unwrap_or_else(|| default_prompt.to_string());
send_message_v2_with_policies(
codex_bin,
config_overrides,
message,
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::ReadOnly),
)
}
fn trigger_patch_approval(
codex_bin: &str,
config_overrides: &[String],
user_message: Option<String>,
) -> Result<()> {
fn trigger_patch_approval(codex_bin: String, user_message: Option<String>) -> Result<()> {
let default_prompt =
"Create a file named APPROVAL_DEMO.txt containing a short hello message using apply_patch.";
let message = user_message.unwrap_or_else(|| default_prompt.to_string());
send_message_v2_with_policies(
codex_bin,
config_overrides,
message,
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::ReadOnly),
)
}
fn no_trigger_cmd_approval(codex_bin: &str, config_overrides: &[String]) -> Result<()> {
fn no_trigger_cmd_approval(codex_bin: String) -> Result<()> {
let prompt = "Run `touch should_not_trigger_approval.txt`";
send_message_v2_with_policies(codex_bin, config_overrides, prompt.to_string(), None, None)
send_message_v2_with_policies(codex_bin, prompt.to_string(), None, None)
}
fn send_message_v2_with_policies(
codex_bin: &str,
config_overrides: &[String],
codex_bin: String,
user_message: String,
approval_policy: Option<AskForApproval>,
sandbox_policy: Option<SandboxPolicy>,
) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
let mut client = CodexClient::spawn(codex_bin)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -264,12 +222,11 @@ fn send_message_v2_with_policies(
}
fn send_follow_up_v2(
codex_bin: &str,
config_overrides: &[String],
codex_bin: String,
first_message: String,
follow_up_message: String,
) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
let mut client = CodexClient::spawn(codex_bin)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -302,8 +259,8 @@ fn send_follow_up_v2(
Ok(())
}
fn test_login(codex_bin: &str, config_overrides: &[String]) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
fn test_login(codex_bin: String) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -332,8 +289,8 @@ fn test_login(codex_bin: &str, config_overrides: &[String]) -> Result<()> {
}
}
fn get_account_rate_limits(codex_bin: &str, config_overrides: &[String]) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
fn get_account_rate_limits(codex_bin: String) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -352,12 +309,8 @@ struct CodexClient {
}
impl CodexClient {
fn spawn(codex_bin: &str, config_overrides: &[String]) -> Result<Self> {
let mut cmd = Command::new(codex_bin);
for override_kv in config_overrides {
cmd.arg("--config").arg(override_kv);
}
let mut codex_app_server = cmd
fn spawn(codex_bin: String) -> Result<Self> {
let mut codex_app_server = Command::new(&codex_bin)
.arg("app-server")
.stdin(Stdio::piped())
.stdout(Stdio::piped())

View File

@@ -27,7 +27,6 @@ codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-feedback = { workspace = true }
codex-rmcp-client = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-json-to-toml = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true, features = ["derive"] }
@@ -48,6 +47,7 @@ uuid = { workspace = true, features = ["serde", "v7"] }
[dev-dependencies]
app_test_support = { workspace = true }
assert_cmd = { workspace = true }
base64 = { workspace = true }
core_test_support = { workspace = true }
mcp-types = { workspace = true }

View File

@@ -3,7 +3,6 @@
`codex app-server` is the interface Codex uses to power rich interfaces such as the [Codex VS Code extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt).
## Table of Contents
- [Protocol](#protocol)
- [Message Schema](#message-schema)
- [Core Primitives](#core-primitives)
@@ -29,7 +28,6 @@ codex app-server generate-json-schema --out DIR
## Core Primitives
The API exposes three top level primitives representing an interaction between a user and Codex:
- **Thread**: A conversation between a user and the Codex agent. Each thread contains multiple turns.
- **Turn**: One turn of the conversation, typically starting with a user message and finishing with an agent message. Each turn contains multiple items.
- **Item**: Represents user inputs and agent outputs as part of the turn, persisted and used as the context for future conversations. Example items include user message, agent reasoning, agent message, shell command, file edit, etc.
@@ -51,23 +49,13 @@ Clients must send a single `initialize` request before invoking any other method
Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter.
Example (from OpenAI's official VSCode extension):
```json
{
"method": "initialize",
"id": 0,
"params": {
"clientInfo": {
"name": "codex-vscode",
"title": "Codex VS Code Extension",
"version": "0.1.0"
}
}
}
{ "method": "initialize", "id": 0, "params": {
"clientInfo": { "name": "codex-vscode", "title": "Codex VS Code Extension", "version": "0.1.0" }
} }
```
## API Overview
- `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders` filtering.
@@ -76,13 +64,13 @@ Example (from OpenAI's official VSCode extension):
- `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"`.
- `review/start` — kick off Codexs automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review.
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
- `model/list`list available models (with reasoning effort options).
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
- `model/list`request the available models; responds with `{}` and asynchronously emits `model/presets/updated` containing the catalog.
- `skills/list` — list skills for one or more `cwd` values.
- `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.
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination.
- `mcpServers/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination.
- `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id.
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
- `config/read` — fetch the effective config on disk after resolving config layering (thread-agnostic; does not include in-repo `.codex/` layers).
- `config/read` — fetch the effective config on disk after resolving config layering.
- `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.
@@ -120,7 +108,6 @@ To continue a stored session, call `thread/resume` with the `thread.id` you prev
### Example: List threads (with pagination & filters)
`thread/list` lets you render a history UI. Pass any combination of:
- `cursor` — opaque string from a prior response; omit for the first page.
- `limit` — server defaults to a reasonable page size if unset.
- `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers.
@@ -162,7 +149,7 @@ Turns attach user input (text or images) to a thread and trigger Codex generatio
- `{"type":"image","url":"https://…png"}`
- `{"type":"localImage","path":"/tmp/screenshot.png"}`
You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn.
You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread.
```json
{ "method": "turn/start", "id": 30, "params": {
@@ -172,20 +159,13 @@ You can optionally specify config overrides on the new turn. If specified, these
"cwd": "/Users/me/project",
"approvalPolicy": "unlessTrusted",
"sandboxPolicy": {
"type": "workspaceWrite",
"mode": "workspaceWrite",
"writableRoots": ["/Users/me/project"],
"networkAccess": true
},
"model": "gpt-5.1-codex",
"effort": "medium",
"summary": "concise",
// Optional JSON Schema to constrain the final assistant message for this turn.
"outputSchema": {
"type": "object",
"properties": { "answer": { "type": "string" } },
"required": ["answer"],
"additionalProperties": false
}
"summary": "concise"
} }
{ "id": 30, "result": { "turn": {
"id": "turn_456",
@@ -248,32 +228,22 @@ Codex streams the usual `turn/started` notification followed by an `item/started
with an `enteredReviewMode` item so clients can show progress:
```json
{
"method": "item/started",
"params": {
"item": {
"type": "enteredReviewMode",
"id": "turn_900",
"review": "current changes"
}
}
}
{ "method": "item/started", "params": { "item": {
"type": "enteredReviewMode",
"id": "turn_900",
"review": "current changes"
} } }
```
When the reviewer finishes, the server emits `item/started` and `item/completed`
containing an `exitedReviewMode` item with the final review text:
```json
{
"method": "item/completed",
"params": {
"item": {
"type": "exitedReviewMode",
"id": "turn_900",
"review": "Looks solid overall...\n\n- Prefer Stylize helpers — app.rs:10-20\n ..."
}
}
}
{ "method": "item/completed", "params": { "item": {
"type": "exitedReviewMode",
"id": "turn_900",
"review": "Looks solid overall...\n\n- Prefer Stylize helpers — app.rs:10-20\n ..."
} } }
```
The `review` string is plain text that already bundles the overall explanation plus a bullet list for each structured finding (matching `ThreadItem::ExitedReviewMode` in the generated schema). Use this notification to render the reviewer output in your client.
@@ -292,12 +262,9 @@ Run a standalone command (argv vector) in the servers sandbox without creatin
{ "id": 32, "result": { "exitCode": 0, "stdout": "...", "stderr": "" } }
```
- For clients that are already sandboxed externally, set `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`.
Notes:
- Empty `command` arrays are rejected.
- `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`).
- `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags).
- When omitted, `timeoutMs` falls back to the server default.
## Events
@@ -309,7 +276,7 @@ Event notifications are the server-initiated event stream for thread lifecycles,
The app-server streams JSON-RPC notifications while a turn is running. Each turn starts with `turn/started` (initial `turn`) and ends with `turn/completed` (final `turn` status). Token usage events stream separately via `thread/tokenUsage/updated`. Clients subscribe to the events they care about, rendering each item incrementally as updates arrive. The per-item lifecycle is always: `item/started` → zero or more item-specific deltas → `item/completed`.
- `turn/started``{ turn }` with the turn id, empty `items`, and `status: "inProgress"`.
- `turn/completed``{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo?, additionalDetails? } }`.
- `turn/completed``{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo? } }`.
- `turn/diff/updated``{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items.
- `turn/plan/updated``{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`.
@@ -318,7 +285,6 @@ Today both notifications carry an empty `items` array even when item events were
#### Items
`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`).
- `agentMessage``{id, text}` containing the accumulated agent reply.
- `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).
@@ -332,48 +298,37 @@ Today both notifications carry an empty `items` array even when item events were
- `compacted` - `{threadId, turnId}` when codex compacts the conversation history. This can happen automatically.
All items emit two shared lifecycle events:
- `item/started` — emits the full `item` when a new unit of work begins so the UI can render it immediately; the `item.id` in this payload matches the `itemId` used by deltas.
- `item/completed` — sends the final `item` once that work finishes (e.g., after a tool call or message completes); treat this as the authoritative state.
There are additional item-specific events:
#### agentMessage
- `item/agentMessage/delta` — appends streamed text for the agent message; concatenate `delta` values for the same `itemId` in order to reconstruct the full reply.
#### reasoning
- `item/reasoning/summaryTextDelta` — streams readable reasoning summaries; `summaryIndex` increments when a new summary section opens.
- `item/reasoning/summaryPartAdded` — marks the boundary between reasoning summary sections for an `itemId`; subsequent `summaryTextDelta` entries share the same `summaryIndex`.
- `item/reasoning/textDelta` — streams raw reasoning text (only applicable for e.g. open source models); use `contentIndex` to group deltas that belong together before showing them in the UI.
#### commandExecution
- `item/commandExecution/outputDelta` — streams stdout/stderr for the command; append deltas in order to render live output alongside `aggregatedOutput` in the final item.
Final `commandExecution` items include parsed `commandActions`, `status`, `exitCode`, and `durationMs` so the UI can summarize what ran and whether it succeeded.
Final `commandExecution` items include parsed `commandActions`, `status`, `exitCode`, and `durationMs` so the UI can summarize what ran and whether it succeeded.
#### fileChange
- `item/fileChange/outputDelta` - contains the tool call response of the underlying `apply_patch` tool call.
### Errors
`error` event is emitted whenever the server hits an error mid-turn (for example, upstream model errors or quota limits). Carries the same `{ error: { message, codexErrorInfo? } }` payload as `turn.status: "failed"` and may precede that terminal notification.
`error` event is emitted whenever the server hits an error mid-turn (for example, upstream model errors or quota limits). Carries the same `{ error: { message, codexErrorInfo?, additionalDetails? } }` payload as `turn.status: "failed"` and may precede that terminal notification.
`codexErrorInfo` maps to the `CodexErrorInfo` enum. Common values:
- `ContextWindowExceeded`
- `UsageLimitExceeded`
- `HttpConnectionFailed { httpStatusCode? }`: upstream HTTP failures including 4xx/5xx
- `ResponseStreamConnectionFailed { httpStatusCode? }`: failure to connect to the response SSE stream
- `ResponseStreamDisconnected { httpStatusCode? }`: disconnect of the response SSE stream in the middle of a turn before completion
- `ResponseTooManyFailedAttempts { httpStatusCode? }`
- `BadRequest`
- `Unauthorized`
- `SandboxError`
- `InternalServerError`
- `Other`: all unclassified errors
`codexErrorInfo` maps to the `CodexErrorInfo` enum. Common values:
- `ContextWindowExceeded`
- `UsageLimitExceeded`
- `HttpConnectionFailed { httpStatusCode? }`: upstream HTTP failures including 4xx/5xx
- `ResponseStreamConnectionFailed { httpStatusCode? }`: failure to connect to the response SSE stream
- `ResponseStreamDisconnected { httpStatusCode? }`: disconnect of the response SSE stream in the middle of a turn before completion
- `ResponseTooManyFailedAttempts { httpStatusCode? }`
- `BadRequest`
- `Unauthorized`
- `SandboxError`
- `InternalServerError`
- `Other`: all unclassified errors
When an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.
@@ -387,7 +342,6 @@ Certain actions (shell commands or modifying files) may require explicit user ap
### Command execution approvals
Order of messages:
1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action.
2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `reason` or `risk`, plus `parsedCmd` for friendly display.
3. Client response — `{ "decision": "accept", "acceptSettings": { "forSession": false } }` or `{ "decision": "decline" }`.
@@ -396,7 +350,6 @@ Order of messages:
### File change approvals
Order of messages:
1. `item/started` — emits a `fileChange` item with `changes` (diff chunk summaries) and `status: "inProgress"`. Show the proposed edits and paths to the user.
2. `item/fileChange/requestApproval` (request) — includes `itemId`, `threadId`, `turnId`, and an optional `reason`.
3. Client response — `{ "decision": "accept" }` or `{ "decision": "decline" }`.
@@ -409,7 +362,6 @@ UI guidance for IDEs: surface an approval dialog as soon as the request arrives.
The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits.
### API Overview
- `account/read` — fetch current account info; optionally refresh tokens.
- `account/login/start` — begin login (`apiKey` or `chatgpt`).
- `account/login/completed` (notify) — emitted when a login attempt finishes (success or error).
@@ -423,13 +375,11 @@ The JSON-RPC auth/account surface exposes request/response methods plus server-i
### 1) Check auth state
Request:
```json
{ "method": "account/read", "id": 1, "params": { "refreshToken": false } }
```
Response examples:
```json
{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": false } } // No OpenAI auth needed (e.g., OSS/local models)
{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models)
@@ -438,7 +388,6 @@ Response examples:
```
Field notes:
- `refreshToken` (bool): set `true` to force a token refresh.
- `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials.
@@ -446,11 +395,7 @@ Field notes:
1. Send:
```json
{
"method": "account/login/start",
"id": 2,
"params": { "type": "apiKey", "apiKey": "sk-…" }
}
{ "method": "account/login/start", "id": 2, "params": { "type": "apiKey", "apiKey": "sk-…" } }
```
2. Expect:
```json
@@ -500,7 +445,6 @@ Field notes:
```
Field notes:
- `usedPercent` is current usage within the OpenAI quota window.
- `windowDurationMins` is the quota window length.
- `resetsAt` is a Unix timestamp (seconds) for the next reset.

View File

@@ -15,7 +15,6 @@ use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::ContextCompactedNotification;
use codex_app_server_protocol::DeprecationNoticeNotification;
use codex_app_server_protocol::ErrorNotification;
use codex_app_server_protocol::ExecCommandApprovalParams;
use codex_app_server_protocol::ExecCommandApprovalResponse;
@@ -32,7 +31,6 @@ use codex_app_server_protocol::McpToolCallResult;
use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::PatchChangeKind as V2PatchChangeKind;
use codex_app_server_protocol::RawResponseItemCompletedNotification;
use codex_app_server_protocol::ReasoningSummaryPartAddedNotification;
use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
use codex_app_server_protocol::ReasoningTextDeltaNotification;
@@ -284,15 +282,6 @@ pub(crate) async fn apply_bespoke_event_handling(
.send_server_notification(ServerNotification::ContextCompacted(notification))
.await;
}
EventMsg::DeprecationNotice(event) => {
let notification = DeprecationNoticeNotification {
summary: event.summary,
details: event.details,
};
outgoing
.send_server_notification(ServerNotification::DeprecationNotice(notification))
.await;
}
EventMsg::ReasoningContentDelta(event) => {
let notification = ReasoningSummaryTextDeltaNotification {
thread_id: conversation_id.to_string(),
@@ -340,7 +329,6 @@ pub(crate) async fn apply_bespoke_event_handling(
let turn_error = TurnError {
message: ev.message,
codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from),
additional_details: None,
};
handle_error(conversation_id, turn_error.clone(), &turn_summary_store).await;
outgoing
@@ -358,7 +346,6 @@ pub(crate) async fn apply_bespoke_event_handling(
let turn_error = TurnError {
message: ev.message,
codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from),
additional_details: ev.additional_details,
};
outgoing
.send_server_notification(ServerNotification::Error(ErrorNotification {
@@ -464,16 +451,6 @@ pub(crate) async fn apply_bespoke_event_handling(
.send_server_notification(ServerNotification::ItemCompleted(completed))
.await;
}
EventMsg::RawResponseItem(raw_response_item_event) => {
maybe_emit_raw_response_item_completed(
api_version,
conversation_id,
&event_turn_id,
raw_response_item_event.item,
outgoing.as_ref(),
)
.await;
}
EventMsg::PatchApplyBegin(patch_begin_event) => {
// Until we migrate the core to be aware of a first class FileChangeItem
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
@@ -843,27 +820,6 @@ async fn complete_command_execution_item(
.await;
}
async fn maybe_emit_raw_response_item_completed(
api_version: ApiVersion,
conversation_id: ConversationId,
turn_id: &str,
item: codex_protocol::models::ResponseItem,
outgoing: &OutgoingMessageSender,
) {
let ApiVersion::V2 = api_version else {
return;
};
let notification = RawResponseItemCompletedNotification {
thread_id: conversation_id.to_string(),
turn_id: turn_id.to_string(),
item,
};
outgoing
.send_server_notification(ServerNotification::RawResponseItemCompleted(notification))
.await;
}
async fn find_and_remove_turn_summary(
conversation_id: ConversationId,
turn_summary_store: &TurnSummaryStore,
@@ -1342,7 +1298,6 @@ mod tests {
TurnError {
message: "boom".to_string(),
codex_error_info: Some(V2CodexErrorInfo::InternalServerError),
additional_details: None,
},
&turn_summary_store,
)
@@ -1354,7 +1309,6 @@ mod tests {
Some(TurnError {
message: "boom".to_string(),
codex_error_info: Some(V2CodexErrorInfo::InternalServerError),
additional_details: None,
})
);
Ok(())
@@ -1402,7 +1356,6 @@ mod tests {
TurnError {
message: "oops".to_string(),
codex_error_info: None,
additional_details: None,
},
&turn_summary_store,
)
@@ -1444,7 +1397,6 @@ mod tests {
TurnError {
message: "bad".to_string(),
codex_error_info: Some(V2CodexErrorInfo::Other),
additional_details: None,
},
&turn_summary_store,
)
@@ -1473,7 +1425,6 @@ mod tests {
Some(TurnError {
message: "bad".to_string(),
codex_error_info: Some(V2CodexErrorInfo::Other),
additional_details: None,
})
);
}
@@ -1698,7 +1649,6 @@ mod tests {
TurnError {
message: "a1".to_string(),
codex_error_info: Some(V2CodexErrorInfo::BadRequest),
additional_details: None,
},
&turn_summary_store,
)
@@ -1718,7 +1668,6 @@ mod tests {
TurnError {
message: "b1".to_string(),
codex_error_info: None,
additional_details: None,
},
&turn_summary_store,
)
@@ -1755,7 +1704,6 @@ mod tests {
Some(TurnError {
message: "a1".to_string(),
codex_error_info: Some(V2CodexErrorInfo::BadRequest),
additional_details: None,
})
);
}
@@ -1776,7 +1724,6 @@ mod tests {
Some(TurnError {
message: "b1".to_string(),
codex_error_info: None,
additional_details: None,
})
);
}

View File

@@ -1,5 +1,4 @@
use crate::bespoke_event_handling::apply_bespoke_event_handling;
use crate::config_api::ConfigApi;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::fuzzy_file_search::run_fuzzy_file_search;
@@ -47,8 +46,8 @@ use codex_app_server_protocol::InterruptConversationParams;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ListConversationsParams;
use codex_app_server_protocol::ListConversationsResponse;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::ListMcpServersParams;
use codex_app_server_protocol::ListMcpServersResponse;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginApiKeyParams;
use codex_app_server_protocol::LoginApiKeyResponse;
@@ -56,12 +55,13 @@ use codex_app_server_protocol::LoginChatGptCompleteNotification;
use codex_app_server_protocol::LoginChatGptResponse;
use codex_app_server_protocol::LogoutAccountResponse;
use codex_app_server_protocol::LogoutChatGptResponse;
use codex_app_server_protocol::McpServer;
use codex_app_server_protocol::McpServerOauthLoginCompletedNotification;
use codex_app_server_protocol::McpServerOauthLoginParams;
use codex_app_server_protocol::McpServerOauthLoginResponse;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::ModelPresetsUpdatedNotification;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::NewConversationResponse;
use codex_app_server_protocol::RemoveConversationListenerParams;
@@ -156,6 +156,7 @@ use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
use codex_protocol::user_input::UserInput as CoreInputItem;
use codex_rmcp_client::perform_oauth_login_return_url;
use codex_utils_json_to_toml::json_to_toml;
use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsStr;
@@ -215,7 +216,7 @@ pub(crate) struct CodexMessageProcessor {
outgoing: Arc<OutgoingMessageSender>,
codex_linux_sandbox_exe: Option<PathBuf>,
config: Arc<Config>,
config_api: ConfigApi,
cli_overrides: Vec<(String, TomlValue)>,
conversation_listeners: HashMap<Uuid, oneshot::Sender<()>>,
active_login: Arc<Mutex<Option<ActiveLogin>>>,
// Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives.
@@ -231,6 +232,19 @@ pub(crate) enum ApiVersion {
V2,
}
fn spawn_model_presets_notification(
outgoing: Arc<OutgoingMessageSender>,
conversation_manager: Arc<ConversationManager>,
config: Arc<Config>,
) {
tokio::spawn(async move {
let models = supported_models(conversation_manager, &config).await;
let notification =
ServerNotification::ModelPresetsUpdated(ModelPresetsUpdatedNotification { models });
outgoing.send_server_notification(notification).await;
});
}
impl CodexMessageProcessor {
async fn conversation_from_thread_id(
&self,
@@ -265,14 +279,13 @@ impl CodexMessageProcessor {
cli_overrides: Vec<(String, TomlValue)>,
feedback: CodexFeedback,
) -> Self {
let config_api = ConfigApi::new(config.codex_home.clone(), cli_overrides.clone());
Self {
auth_manager,
conversation_manager,
outgoing,
codex_linux_sandbox_exe,
config,
config_api,
cli_overrides,
conversation_listeners: HashMap::new(),
active_login: Arc::new(Mutex::new(None)),
pending_interrupts: Arc::new(Mutex::new(HashMap::new())),
@@ -282,8 +295,22 @@ impl CodexMessageProcessor {
}
}
pub(crate) fn spawn_model_presets_notification(&self) {
spawn_model_presets_notification(
self.outgoing.clone(),
self.conversation_manager.clone(),
self.config.clone(),
);
}
async fn load_latest_config(&self) -> Result<Config, JSONRPCErrorError> {
self.config_api.load_latest_thread_agnostic_config().await
Config::load_with_cli_overrides(self.cli_overrides.clone(), ConfigOverrides::default())
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to reload config: {err}"),
data: None,
})
}
fn review_request_from_target(
@@ -388,20 +415,13 @@ impl CodexMessageProcessor {
self.handle_list_conversations(request_id, params).await;
}
ClientRequest::ModelList { request_id, params } => {
let outgoing = self.outgoing.clone();
let conversation_manager = self.conversation_manager.clone();
let config = self.config.clone();
tokio::spawn(async move {
Self::list_models(outgoing, conversation_manager, config, request_id, params)
.await;
});
self.list_models(request_id, params).await;
}
ClientRequest::McpServerOauthLogin { request_id, params } => {
self.mcp_server_oauth_login(request_id, params).await;
}
ClientRequest::McpServerStatusList { request_id, params } => {
self.list_mcp_server_status(request_id, params).await;
ClientRequest::McpServersList { request_id, params } => {
self.list_mcp_servers(request_id, params).await;
}
ClientRequest::LoginAccount { request_id, params } => {
self.login_v2(request_id, params).await;
@@ -575,6 +595,7 @@ impl CodexMessageProcessor {
self.outgoing
.send_server_notification(ServerNotification::AuthStatusChange(payload))
.await;
self.spawn_model_presets_notification();
}
Err(error) => {
self.outgoing.send_error(request_id, error).await;
@@ -605,6 +626,7 @@ impl CodexMessageProcessor {
self.outgoing
.send_server_notification(ServerNotification::AccountUpdated(payload_v2))
.await;
self.spawn_model_presets_notification();
}
Err(error) => {
self.outgoing.send_error(request_id, error).await;
@@ -661,6 +683,8 @@ impl CodexMessageProcessor {
let outgoing_clone = self.outgoing.clone();
let active_login = self.active_login.clone();
let auth_manager = self.auth_manager.clone();
let conversation_manager = self.conversation_manager.clone();
let config = self.config.clone();
let auth_url = server.auth_url.clone();
tokio::spawn(async move {
let (success, error_msg) = match tokio::time::timeout(
@@ -701,6 +725,11 @@ impl CodexMessageProcessor {
payload,
))
.await;
spawn_model_presets_notification(
outgoing_clone,
conversation_manager,
config,
);
}
// Clear the active login if it matches this attempt. It may have been replaced or cancelled.
@@ -751,6 +780,8 @@ impl CodexMessageProcessor {
let outgoing_clone = self.outgoing.clone();
let active_login = self.active_login.clone();
let auth_manager = self.auth_manager.clone();
let conversation_manager = self.conversation_manager.clone();
let config = self.config.clone();
let auth_url = server.auth_url.clone();
tokio::spawn(async move {
let (success, error_msg) = match tokio::time::timeout(
@@ -791,6 +822,11 @@ impl CodexMessageProcessor {
payload_v2,
))
.await;
spawn_model_presets_notification(
outgoing_clone,
conversation_manager,
config,
);
}
// Clear the active login if it matches this attempt. It may have been replaced or cancelled.
@@ -910,6 +946,7 @@ impl CodexMessageProcessor {
self.outgoing
.send_server_notification(ServerNotification::AuthStatusChange(payload))
.await;
self.spawn_model_presets_notification();
}
Err(error) => {
self.outgoing.send_error(request_id, error).await;
@@ -930,6 +967,7 @@ impl CodexMessageProcessor {
self.outgoing
.send_server_notification(ServerNotification::AccountUpdated(payload_v2))
.await;
self.spawn_model_presets_notification();
}
Err(error) => {
self.outgoing.send_error(request_id, error).await;
@@ -1181,22 +1219,10 @@ impl CodexMessageProcessor {
arg0: None,
};
let requested_policy = params.sandbox_policy.map(|policy| policy.to_core());
let effective_policy = match requested_policy {
Some(policy) => match self.config.sandbox_policy.can_set(&policy) {
Ok(()) => policy,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid sandbox policy: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
},
None => self.config.sandbox_policy.get().clone(),
};
let effective_policy = params
.sandbox_policy
.map(|policy| policy.to_core())
.unwrap_or_else(|| self.config.sandbox_policy.clone());
let codex_linux_sandbox_exe = self.config.codex_linux_sandbox_exe.clone();
let outgoing = self.outgoing.clone();
@@ -1273,20 +1299,18 @@ impl CodexMessageProcessor {
);
}
let config =
match derive_config_from_params(&self.config_api, overrides, Some(cli_overrides)).await
{
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let config = match derive_config_from_params(overrides, Some(cli_overrides)).await {
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
match self.conversation_manager.new_conversation(config).await {
Ok(conversation_id) => {
@@ -1325,19 +1349,18 @@ impl CodexMessageProcessor {
params.developer_instructions,
);
let config =
match derive_config_from_params(&self.config_api, overrides, params.config).await {
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let config = match derive_config_from_params(overrides, params.config).await {
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
match self.conversation_manager.new_conversation(config).await {
Ok(new_conv) => {
@@ -1390,13 +1413,9 @@ impl CodexMessageProcessor {
};
// Auto-attach a conversation listener when starting a thread.
// Use the same behavior as the v1 API, with opt-in support for raw item events.
// Use the same behavior as the v1 API with experimental_raw_events=false.
if let Err(err) = self
.attach_conversation_listener(
conversation_id,
params.experimental_raw_events,
ApiVersion::V2,
)
.attach_conversation_listener(conversation_id, false, ApiVersion::V2)
.await
{
tracing::warn!(
@@ -1565,7 +1584,7 @@ impl CodexMessageProcessor {
base_instructions,
developer_instructions,
);
match derive_config_from_params(&self.config_api, overrides, cli_overrides).await {
match derive_config_from_params(overrides, cli_overrides).await {
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
@@ -1913,68 +1932,12 @@ impl CodexMessageProcessor {
Ok((items, next_cursor))
}
async fn list_models(
outgoing: Arc<OutgoingMessageSender>,
conversation_manager: Arc<ConversationManager>,
config: Arc<Config>,
request_id: RequestId,
params: ModelListParams,
) {
let ModelListParams { limit, cursor } = params;
let mut config = (*config).clone();
config.features.enable(Feature::RemoteModels);
let models = supported_models(conversation_manager, &config).await;
let total = models.len();
async fn list_models(&self, request_id: RequestId, params: ModelListParams) {
let _ = params;
let response = ModelListResponse {};
self.outgoing.send_response(request_id, response).await;
if total == 0 {
let response = ModelListResponse {
data: Vec::new(),
next_cursor: None,
};
outgoing.send_response(request_id, response).await;
return;
}
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) => match cursor.parse::<usize>() {
Ok(idx) => idx,
Err(_) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid cursor: {cursor}"),
data: None,
};
outgoing.send_error(request_id, error).await;
return;
}
},
None => 0,
};
if start > total {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("cursor {start} exceeds total models {total}"),
data: None,
};
outgoing.send_error(request_id, error).await;
return;
}
let end = start.saturating_add(effective_limit).min(total);
let items = models[start..end].to_vec();
let next_cursor = if end < total {
Some(end.to_string())
} else {
None
};
let response = ModelListResponse {
data: items,
next_cursor,
};
outgoing.send_response(request_id, response).await;
self.spawn_model_presets_notification();
}
async fn mcp_server_oauth_login(
@@ -1990,6 +1953,16 @@ impl CodexMessageProcessor {
}
};
if !config.features.enabled(Feature::RmcpClient) {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "OAuth login is only supported when [features].rmcp_client is true in config.toml".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
let McpServerOauthLoginParams {
name,
scopes,
@@ -2071,12 +2044,7 @@ impl CodexMessageProcessor {
}
}
async fn list_mcp_server_status(
&self,
request_id: RequestId,
params: ListMcpServerStatusParams,
) {
let outgoing = Arc::clone(&self.outgoing);
async fn list_mcp_servers(&self, request_id: RequestId, params: ListMcpServersParams) {
let config = match self.load_latest_config().await {
Ok(config) => config,
Err(error) => {
@@ -2085,17 +2053,6 @@ impl CodexMessageProcessor {
}
};
tokio::spawn(async move {
Self::list_mcp_server_status_task(outgoing, request_id, params, config).await;
});
}
async fn list_mcp_server_status_task(
outgoing: Arc<OutgoingMessageSender>,
request_id: RequestId,
params: ListMcpServerStatusParams,
config: Config,
) {
let snapshot = collect_mcp_snapshot(&config).await;
let tools_by_server = group_tools_by_server(&snapshot.tools);
@@ -2123,7 +2080,7 @@ impl CodexMessageProcessor {
message: format!("invalid cursor: {cursor}"),
data: None,
};
outgoing.send_error(request_id, error).await;
self.outgoing.send_error(request_id, error).await;
return;
}
},
@@ -2136,15 +2093,15 @@ impl CodexMessageProcessor {
message: format!("cursor {start} exceeds total MCP servers {total}"),
data: None,
};
outgoing.send_error(request_id, error).await;
self.outgoing.send_error(request_id, error).await;
return;
}
let end = start.saturating_add(effective_limit).min(total);
let data: Vec<McpServerStatus> = server_names[start..end]
let data: Vec<McpServer> = server_names[start..end]
.iter()
.map(|name| McpServerStatus {
.map(|name| McpServer {
name: name.clone(),
tools: tools_by_server.get(name).cloned().unwrap_or_default(),
resources: snapshot.resources.get(name).cloned().unwrap_or_default(),
@@ -2168,9 +2125,9 @@ impl CodexMessageProcessor {
None
};
let response = ListMcpServerStatusResponse { data, next_cursor };
let response = ListMcpServersResponse { data, next_cursor };
outgoing.send_response(request_id, response).await;
self.outgoing.send_response(request_id, response).await;
}
async fn handle_resume_conversation(
@@ -2226,7 +2183,7 @@ impl CodexMessageProcessor {
..Default::default()
};
derive_config_from_params(&self.config_api, overrides, Some(cli_overrides)).await
derive_config_from_params(overrides, Some(cli_overrides)).await
}
None => Ok(self.config.as_ref().clone()),
};
@@ -2577,7 +2534,6 @@ impl CodexMessageProcessor {
let _ = conversation
.submit(Op::UserInput {
items: mapped_items,
final_output_json_schema: None,
})
.await;
@@ -2597,7 +2553,6 @@ impl CodexMessageProcessor {
model,
effort,
summary,
output_schema,
} = params;
let Ok(conversation) = self
@@ -2632,7 +2587,7 @@ impl CodexMessageProcessor {
model,
effort,
summary,
final_output_json_schema: output_schema,
final_output_json_schema: None,
})
.await;
@@ -2642,27 +2597,36 @@ impl CodexMessageProcessor {
}
async fn skills_list(&self, request_id: RequestId, params: SkillsListParams) {
let SkillsListParams { cwds, force_reload } = params;
let SkillsListParams { cwds } = params;
let cwds = if cwds.is_empty() {
vec![self.config.cwd.clone()]
} else {
cwds
};
let skills_manager = self.conversation_manager.skills_manager();
let data = cwds
.into_iter()
.map(|cwd| {
let outcome = skills_manager.skills_for_cwd_with_options(&cwd, force_reload);
let errors = errors_to_info(&outcome.errors);
let skills = skills_to_info(&outcome.skills);
codex_app_server_protocol::SkillsListEntry {
let data = if self.config.features.enabled(Feature::Skills) {
let skills_manager = self.conversation_manager.skills_manager();
cwds.into_iter()
.map(|cwd| {
let outcome = skills_manager.skills_for_cwd(&cwd);
let errors = errors_to_info(&outcome.errors);
let skills = skills_to_info(&outcome.skills);
codex_app_server_protocol::SkillsListEntry {
cwd,
skills,
errors,
}
})
.collect()
} else {
cwds.into_iter()
.map(|cwd| codex_app_server_protocol::SkillsListEntry {
cwd,
skills,
errors,
}
})
.collect();
skills: Vec::new(),
errors: Vec::new(),
})
.collect()
};
self.outgoing
.send_response(request_id, SkillsListResponse { data })
.await;
@@ -2741,7 +2705,6 @@ impl CodexMessageProcessor {
let turn_id = conversation
.submit(Op::UserInput {
items: mapped_items,
final_output_json_schema: params.output_schema,
})
.await;
@@ -3322,7 +3285,6 @@ fn skills_to_info(
.map(|skill| codex_app_server_protocol::SkillMetadata {
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
path: skill.path.clone(),
scope: skill.scope.into(),
})
@@ -3342,13 +3304,16 @@ fn errors_to_info(
}
async fn derive_config_from_params(
config_api: &ConfigApi,
overrides: ConfigOverrides,
cli_overrides: Option<HashMap<String, serde_json::Value>>,
cli_overrides: Option<std::collections::HashMap<String, serde_json::Value>>,
) -> std::io::Result<Config> {
config_api
.load_thread_agnostic_config(overrides, cli_overrides)
.await
let cli_overrides = cli_overrides
.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, json_to_toml(v)))
.collect();
Config::load_with_cli_overrides(cli_overrides, overrides).await
}
async fn read_summary_from_rollout(

View File

@@ -7,28 +7,21 @@ use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteErrorCode;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigService;
use codex_core::config::ConfigServiceError;
use codex_utils_json_to_toml::json_to_toml;
use serde_json::json;
use std::path::PathBuf;
use toml::Value as TomlValue;
#[derive(Clone)]
pub(crate) struct ConfigApi {
codex_home: PathBuf,
cli_overrides: Vec<(String, TomlValue)>,
service: ConfigService,
}
impl ConfigApi {
pub(crate) fn new(codex_home: PathBuf, cli_overrides: Vec<(String, TomlValue)>) -> Self {
Self {
service: ConfigService::new(codex_home.clone(), cli_overrides.clone()),
codex_home,
cli_overrides,
service: ConfigService::new(codex_home, cli_overrides),
}
}
@@ -39,30 +32,6 @@ impl ConfigApi {
self.service.read(params).await.map_err(map_error)
}
pub(crate) async fn load_thread_agnostic_config(
&self,
overrides: codex_core::config::ConfigOverrides,
request_cli_overrides: Option<std::collections::HashMap<String, serde_json::Value>>,
) -> std::io::Result<Config> {
// Apply the app server's startup `--config` overrides, then apply request-scoped overrides
// with higher precedence.
let mut merged_cli_overrides = self.cli_overrides.clone();
merged_cli_overrides.extend(
request_cli_overrides
.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, json_to_toml(v))),
);
ConfigBuilder::default()
.codex_home(self.codex_home.clone())
.cli_overrides(merged_cli_overrides)
.harness_overrides(overrides)
.thread_agnostic()
.build()
.await
}
pub(crate) async fn write_value(
&self,
params: ConfigValueWriteParams,
@@ -76,18 +45,6 @@ impl ConfigApi {
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
self.service.batch_write(params).await.map_err(map_error)
}
pub(crate) async fn load_latest_thread_agnostic_config(
&self,
) -> Result<Config, JSONRPCErrorError> {
self.load_thread_agnostic_config(codex_core::config::ConfigOverrides::default(), None)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to reload config: {err}"),
data: None,
})
}
}
fn map_error(err: ConfigServiceError) -> JSONRPCErrorError {

View File

@@ -1,5 +1,6 @@
use std::num::NonZero;
use std::num::NonZeroUsize;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
@@ -62,7 +63,11 @@ pub(crate) async fn run_fuzzy_file_search(
Ok(Ok((root, res))) => {
for m in res.matches {
let path = m.path;
let file_name = file_search::file_name_from_path(&path);
//TODO(shijie): Move file name generation to file_search lib.
let file_name = Path::new(&path)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.clone());
let result = FuzzyFileSearchResult {
root: root.clone(),
path,

View File

@@ -2,6 +2,7 @@
use codex_common::CliConfigOverrides;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use std::io::ErrorKind;
use std::io::Result as IoResult;
use std::path::PathBuf;
@@ -17,11 +18,13 @@ use tokio::io::BufReader;
use tokio::io::{self};
use tokio::sync::mpsc;
use toml::Value as TomlValue;
use tracing::Level;
use tracing::debug;
use tracing::error;
use tracing::info;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::Layer;
use tracing_subscriber::filter::Targets;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
@@ -78,11 +81,12 @@ pub async fn run_main(
format!("error parsing -c overrides: {e}"),
)
})?;
let config = Config::load_with_cli_overrides(cli_kv_overrides.clone())
.await
.map_err(|e| {
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))
})?;
let config =
Config::load_with_cli_overrides(cli_kv_overrides.clone(), ConfigOverrides::default())
.await
.map_err(|e| {
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))
})?;
let feedback = CodexFeedback::new();
@@ -101,8 +105,11 @@ pub async fn run_main(
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL)
.with_filter(EnvFilter::from_default_env());
let feedback_layer = feedback.logger_layer();
let feedback_metadata_layer = feedback.metadata_layer();
let feedback_layer = tracing_subscriber::fmt::layer()
.with_writer(feedback.make_writer())
.with_ansi(false)
.with_target(false)
.with_filter(Targets::new().with_default(Level::TRACE));
let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer());
@@ -111,7 +118,6 @@ pub async fn run_main(
let _ = tracing_subscriber::registry()
.with(stderr_fmt)
.with(feedback_layer)
.with(feedback_metadata_layer)
.with(otel_logger_layer)
.with(otel_tracing_layer)
.try_init();

View File

@@ -128,6 +128,8 @@ impl MessageProcessor {
self.outgoing.send_response(request_id, response).await;
self.initialized = true;
self.codex_message_processor
.spawn_model_presets_notification();
return;
}

View File

@@ -9,12 +9,12 @@ path = "lib.rs"
[dependencies]
anyhow = { workspace = true }
assert_cmd = { workspace = true }
base64 = { workspace = true }
chrono = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-core = { workspace = true, features = ["test-support"] }
codex-protocol = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = [

View File

@@ -11,6 +11,7 @@ use tokio::process::ChildStdin;
use tokio::process::ChildStdout;
use anyhow::Context;
use assert_cmd::prelude::*;
use codex_app_server_protocol::AddConversationListenerParams;
use codex_app_server_protocol::ArchiveConversationParams;
use codex_app_server_protocol::CancelLoginAccountParams;
@@ -48,6 +49,7 @@ use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnStartParams;
use std::process::Command as StdCommand;
use tokio::process::Command;
pub struct McpProcess {
@@ -76,8 +78,12 @@ impl McpProcess {
codex_home: &Path,
env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
let program = codex_utils_cargo_bin::cargo_bin("codex-app-server")
.context("should find binary for codex-app-server")?;
// Use assert_cmd to locate the binary path and then switch to tokio::process::Command
let std_cmd = StdCommand::cargo_bin("codex-app-server")
.context("should find binary for codex-mcp-server")?;
let program = std_cmd.get_program().to_owned();
let mut cmd = Command::new(program);
cmd.stdin(Stdio::piped());

View File

@@ -1,10 +1,12 @@
use chrono::DateTime;
use chrono::Utc;
use codex_core::models_manager::model_presets::all_model_presets;
use codex_core::openai_models::model_presets::all_model_presets;
use codex_protocol::openai_models::ClientVersion;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::ReasoningSummaryFormat;
use codex_protocol::openai_models::TruncationPolicyConfig;
use serde_json::json;
use std::path::Path;
@@ -23,6 +25,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
} else {
ModelVisibility::Hide
},
minimal_client_version: ClientVersion(0, 1, 0),
supported_in_api: true,
priority,
upgrade: preset.upgrade.as_ref().map(|u| u.id.clone()),
@@ -34,11 +37,11 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
context_window: None,
reasoning_summary_format: ReasoningSummaryFormat::None,
experimental_supported_tools: Vec::new(),
}
}
// todo(aibrahim): fix the priorities to be the opposite here.
/// Write a models_cache.json file to the codex home directory.
/// This prevents ModelsManager from making network requests to refresh models.
/// The cache will be treated as fresh (within TTL) and used instead of fetching from the network.

View File

@@ -305,7 +305,6 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> {
model: "mock-model".to_string(),
effort: Some(ReasoningEffort::Medium),
summary: ReasoningSummary::Auto,
output_schema: None,
})
.await?;
// Acknowledge sendUserTurn
@@ -419,7 +418,6 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() -> Result<(
model: model.clone(),
effort: Some(ReasoningEffort::Medium),
summary: ReasoningSummary::Auto,
output_schema: None,
})
.await?;
timeout(
@@ -445,7 +443,6 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() -> Result<(
model: model.clone(),
effort: Some(ReasoningEffort::Medium),
summary: ReasoningSummary::Auto,
output_schema: None,
})
.await?;
timeout(

View File

@@ -7,7 +7,6 @@ mod fuzzy_file_search;
mod interrupt;
mod list_resume;
mod login;
mod output_schema;
mod send_message;
mod set_default_model;
mod user_agent;

View File

@@ -1,282 +0,0 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::AddConversationListenerParams;
use codex_app_server_protocol::InputItem;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::NewConversationResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SendUserTurnParams;
use codex_app_server_protocol::SendUserTurnResponse;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::openai_models::ReasoningEffort;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn send_user_turn_accepts_output_schema_v1() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let body = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let response_mock = responses::mount_sse_once(&server, body).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 new_conv_id = mcp
.send_new_conversation_request(NewConversationParams {
..Default::default()
})
.await?;
let new_conv_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)),
)
.await??;
let NewConversationResponse {
conversation_id, ..
} = to_response::<NewConversationResponse>(new_conv_resp)?;
let listener_id = mcp
.send_add_conversation_listener_request(AddConversationListenerParams {
conversation_id,
experimental_raw_events: false,
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(listener_id)),
)
.await??;
let output_schema = serde_json::json!({
"type": "object",
"properties": {
"answer": { "type": "string" }
},
"required": ["answer"],
"additionalProperties": false
});
let send_turn_id = mcp
.send_send_user_turn_request(SendUserTurnParams {
conversation_id,
items: vec![InputItem::Text {
text: "Hello".to_string(),
}],
cwd: codex_home.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: "mock-model".to_string(),
effort: Some(ReasoningEffort::Medium),
summary: ReasoningSummary::Auto,
output_schema: Some(output_schema.clone()),
})
.await?;
let _send_turn_resp: SendUserTurnResponse = to_response::<SendUserTurnResponse>(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(send_turn_id)),
)
.await??,
)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await??;
let request = response_mock.single_request();
let payload = request.body_json();
let text = payload.get("text").expect("request missing text field");
let format = text
.get("format")
.expect("request missing text.format field");
assert_eq!(
format,
&serde_json::json!({
"name": "codex_output_schema",
"type": "json_schema",
"strict": true,
"schema": output_schema,
})
);
Ok(())
}
#[tokio::test]
async fn send_user_turn_output_schema_is_per_turn_v1() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let body1 = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let response_mock1 = responses::mount_sse_once(&server, body1).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 new_conv_id = mcp
.send_new_conversation_request(NewConversationParams {
..Default::default()
})
.await?;
let new_conv_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)),
)
.await??;
let NewConversationResponse {
conversation_id, ..
} = to_response::<NewConversationResponse>(new_conv_resp)?;
let listener_id = mcp
.send_add_conversation_listener_request(AddConversationListenerParams {
conversation_id,
experimental_raw_events: false,
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(listener_id)),
)
.await??;
let output_schema = serde_json::json!({
"type": "object",
"properties": {
"answer": { "type": "string" }
},
"required": ["answer"],
"additionalProperties": false
});
let send_turn_id = mcp
.send_send_user_turn_request(SendUserTurnParams {
conversation_id,
items: vec![InputItem::Text {
text: "Hello".to_string(),
}],
cwd: codex_home.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: "mock-model".to_string(),
effort: Some(ReasoningEffort::Medium),
summary: ReasoningSummary::Auto,
output_schema: Some(output_schema.clone()),
})
.await?;
let _send_turn_resp: SendUserTurnResponse = to_response::<SendUserTurnResponse>(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(send_turn_id)),
)
.await??,
)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await??;
let payload1 = response_mock1.single_request().body_json();
assert_eq!(
payload1.pointer("/text/format"),
Some(&serde_json::json!({
"name": "codex_output_schema",
"type": "json_schema",
"strict": true,
"schema": output_schema,
}))
);
let body2 = responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_assistant_message("msg-2", "Done"),
responses::ev_completed("resp-2"),
]);
let response_mock2 = responses::mount_sse_once(&server, body2).await;
let send_turn_id_2 = mcp
.send_send_user_turn_request(SendUserTurnParams {
conversation_id,
items: vec![InputItem::Text {
text: "Hello again".to_string(),
}],
cwd: codex_home.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: "mock-model".to_string(),
effort: Some(ReasoningEffort::Medium),
summary: ReasoningSummary::Auto,
output_schema: None,
})
.await?;
let _send_turn_resp_2: SendUserTurnResponse = to_response::<SendUserTurnResponse>(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(send_turn_id_2)),
)
.await??,
)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await??;
let payload2 = response_mock2.single_request().body_json();
assert_eq!(payload2.pointer("/text/format"), None);
Ok(())
}
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
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
"#
),
)
}

View File

@@ -25,13 +25,12 @@ async fn get_user_agent_returns_current_codex_user_agent() -> Result<()> {
.await??;
let os_info = os_info::get();
let originator = codex_core::default_client::originator().value.as_str();
let os_type = os_info.os_type();
let os_version = os_info.version();
let architecture = os_info.architecture().unwrap_or("unknown");
let terminal_ua = codex_core::terminal::user_agent();
let user_agent = format!(
"{originator}/0.0.0 ({os_type} {os_version}; {architecture}) {terminal_ua} (codex-app-server-tests; 0.1.0)"
"codex_cli_rs/0.0.0 ({} {}; {}) {} (codex-app-server-tests; 0.1.0)",
os_info.os_type(),
os_info.version(),
os_info.architecture().unwrap_or("unknown"),
codex_core::terminal::user_agent()
);
let received: GetUserAgentResponse = to_response(response)?;

View File

@@ -6,7 +6,7 @@ use app_test_support::to_response;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigEdit;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ConfigLayerName;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ConfigValueWriteParams;
@@ -18,8 +18,6 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::ToolsV2;
use codex_app_server_protocol::WriteStatus;
use codex_core::config_loader::SYSTEM_CONFIG_TOML_FILE_UNIX;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
@@ -44,8 +42,6 @@ model = "gpt-user"
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?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -69,12 +65,12 @@ sandbox_mode = "workspace-write"
assert_eq!(config.model.as_deref(), Some("gpt-user"));
assert_eq!(
origins.get("model").expect("origin").name,
ConfigLayerSource::User {
file: user_file.clone(),
}
ConfigLayerName::User
);
let layers = layers.expect("layers present");
assert_layers_user_then_optional_system(&layers, user_file)?;
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].name, ConfigLayerName::SessionFlags);
assert_eq!(layers[1].name, ConfigLayerName::User);
Ok(())
}
@@ -92,8 +88,6 @@ web_search = true
view_image = false
"#,
)?;
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?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -124,19 +118,17 @@ view_image = false
);
assert_eq!(
origins.get("tools.web_search").expect("origin").name,
ConfigLayerSource::User {
file: user_file.clone(),
}
ConfigLayerName::User
);
assert_eq!(
origins.get("tools.view_image").expect("origin").name,
ConfigLayerSource::User {
file: user_file.clone(),
}
ConfigLayerName::User
);
let layers = layers.expect("layers present");
assert_layers_user_then_optional_system(&layers, user_file)?;
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].name, ConfigLayerName::SessionFlags);
assert_eq!(layers[1].name, ConfigLayerName::User);
Ok(())
}
@@ -161,11 +153,8 @@ network_access = true
serde_json::json!(user_dir)
),
)?;
let codex_home_path = codex_home.path().canonicalize()?;
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
let managed_path = codex_home.path().join("managed_config.toml");
let managed_file = AbsolutePathBuf::try_from(managed_path.clone())?;
std::fs::write(
&managed_path,
format!(
@@ -208,25 +197,19 @@ writable_roots = [{}]
assert_eq!(config.model.as_deref(), Some("gpt-system"));
assert_eq!(
origins.get("model").expect("origin").name,
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: managed_file.clone(),
}
ConfigLayerName::System
);
assert_eq!(config.approval_policy, Some(AskForApproval::Never));
assert_eq!(
origins.get("approval_policy").expect("origin").name,
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: managed_file.clone(),
}
ConfigLayerName::System
);
assert_eq!(config.sandbox_mode, Some(SandboxMode::WorkspaceWrite));
assert_eq!(
origins.get("sandbox_mode").expect("origin").name,
ConfigLayerSource::User {
file: user_file.clone(),
}
ConfigLayerName::User
);
let sandbox = config
@@ -239,9 +222,7 @@ writable_roots = [{}]
.get("sandbox_workspace_write.writable_roots.0")
.expect("origin")
.name,
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: managed_file.clone(),
}
ConfigLayerName::System
);
assert!(sandbox.network_access);
@@ -250,29 +231,29 @@ writable_roots = [{}]
.get("sandbox_workspace_write.network_access")
.expect("origin")
.name,
ConfigLayerSource::User {
file: user_file.clone(),
}
ConfigLayerName::User
);
let layers = layers.expect("layers present");
assert_layers_managed_user_then_optional_system(&layers, managed_file, user_file)?;
assert_eq!(layers.len(), 3);
assert_eq!(layers[0].name, ConfigLayerName::System);
assert_eq!(layers[1].name, ConfigLayerName::SessionFlags);
assert_eq!(layers[2].name, ConfigLayerName::User);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_value_write_replaces_value() -> Result<()> {
let temp_dir = TempDir::new()?;
let codex_home = temp_dir.path().canonicalize()?;
let codex_home = TempDir::new()?;
write_config(
&temp_dir,
&codex_home,
r#"
model = "gpt-old"
"#,
)?;
let mut mcp = McpProcess::new(&codex_home).await?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let read_id = mcp
@@ -303,7 +284,13 @@ model = "gpt-old"
)
.await??;
let write: ConfigWriteResponse = to_response(write_resp)?;
let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home)?;
let expected_file_path = codex_home
.path()
.join("config.toml")
.canonicalize()
.unwrap()
.display()
.to_string();
assert_eq!(write.status, WriteStatus::Ok);
assert_eq!(write.file_path, expected_file_path);
@@ -366,17 +353,16 @@ model = "gpt-old"
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_batch_write_applies_multiple_edits() -> Result<()> {
let tmp_dir = TempDir::new()?;
let codex_home = tmp_dir.path().canonicalize()?;
write_config(&tmp_dir, "")?;
let codex_home = TempDir::new()?;
write_config(&codex_home, "")?;
let mut mcp = McpProcess::new(&codex_home).await?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let writable_root = test_tmp_path_buf();
let batch_id = mcp
.send_config_batch_write_request(ConfigBatchWriteParams {
file_path: Some(codex_home.join("config.toml").display().to_string()),
file_path: Some(codex_home.path().join("config.toml").display().to_string()),
edits: vec![
ConfigEdit {
key_path: "sandbox_mode".to_string(),
@@ -402,7 +388,13 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> {
.await??;
let batch_write: ConfigWriteResponse = to_response(batch_resp)?;
assert_eq!(batch_write.status, WriteStatus::Ok);
let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home)?;
let expected_file_path = codex_home
.path()
.join("config.toml")
.canonicalize()
.unwrap()
.display()
.to_string();
assert_eq!(batch_write.file_path, expected_file_path);
let read_id = mcp
@@ -427,50 +419,3 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> {
Ok(())
}
fn assert_layers_user_then_optional_system(
layers: &[codex_app_server_protocol::ConfigLayer],
user_file: AbsolutePathBuf,
) -> Result<()> {
if cfg!(unix) {
let system_file = AbsolutePathBuf::from_absolute_path(SYSTEM_CONFIG_TOML_FILE_UNIX)?;
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].name, ConfigLayerSource::User { file: user_file });
assert_eq!(
layers[1].name,
ConfigLayerSource::System { file: system_file }
);
} else {
assert_eq!(layers.len(), 1);
assert_eq!(layers[0].name, ConfigLayerSource::User { file: user_file });
}
Ok(())
}
fn assert_layers_managed_user_then_optional_system(
layers: &[codex_app_server_protocol::ConfigLayer],
managed_file: AbsolutePathBuf,
user_file: AbsolutePathBuf,
) -> Result<()> {
if cfg!(unix) {
let system_file = AbsolutePathBuf::from_absolute_path(SYSTEM_CONFIG_TOML_FILE_UNIX)?;
assert_eq!(layers.len(), 3);
assert_eq!(
layers[0].name,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file }
);
assert_eq!(layers[1].name, ConfigLayerSource::User { file: user_file });
assert_eq!(
layers[2].name,
ConfigLayerSource::System { file: system_file }
);
} else {
assert_eq!(layers.len(), 2);
assert_eq!(
layers[0].name,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file }
);
assert_eq!(layers[1].name, ConfigLayerSource::User { file: user_file });
}
Ok(())
}

View File

@@ -1,7 +1,6 @@
mod account;
mod config_rpc;
mod model_list;
mod output_schema;
mod rate_limits;
mod review;
mod thread_archive;

View File

@@ -1,27 +1,26 @@
use std::time::Duration;
use anyhow::Result;
use anyhow::anyhow;
use app_test_support::McpProcess;
use app_test_support::to_response;
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::Model;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::ReasoningEffortOption;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
#[tokio::test]
async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
async fn list_models_returns_empty_response_and_notification() -> Result<()> {
let codex_home = TempDir::new()?;
write_models_cache(codex_home.path())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
@@ -30,8 +29,8 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
let request_id = mcp
.send_list_models_request(ModelListParams {
limit: Some(100),
cursor: None,
limit: Some(1),
cursor: Some("ignored".to_string()),
})
.await?;
@@ -41,12 +40,93 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
)
.await??;
let ModelListResponse {
data: items,
next_cursor,
} = to_response::<ModelListResponse>(response)?;
let ModelListResponse {} = to_response::<ModelListResponse>(response)?;
let expected_models = vec![
let notification: JSONRPCNotification = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("model/presets/updated"),
)
.await??;
let server_notification: ServerNotification = notification.try_into()?;
let ServerNotification::ModelPresetsUpdated(payload) = server_notification else {
unreachable!("expected model/presets/updated notification");
};
assert_eq!(payload.models, expected_models());
Ok(())
}
fn expected_models() -> Vec<Model> {
vec![
Model {
id: "gpt-5.1-codex-max".to_string(),
model: "gpt-5.1-codex-max".to_string(),
display_name: "gpt-5.1-codex-max".to_string(),
description: "Latest Codex-optimized flagship for deep and fast reasoning.".to_string(),
supported_reasoning_efforts: vec![
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Low,
description: "Fast responses with lighter reasoning".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Medium,
description: "Balances speed and reasoning depth for everyday tasks"
.to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::High,
description: "Greater reasoning depth for complex problems".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::XHigh,
description: "Extra high reasoning depth for complex problems".to_string(),
},
],
default_reasoning_effort: ReasoningEffort::Medium,
is_default: true,
},
Model {
id: "gpt-5.1-codex".to_string(),
model: "gpt-5.1-codex".to_string(),
display_name: "gpt-5.1-codex".to_string(),
description: "Optimized for codex.".to_string(),
supported_reasoning_efforts: vec![
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Low,
description: "Fastest responses with limited reasoning".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Medium,
description: "Dynamically adjusts reasoning based on the task".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex or ambiguous problems"
.to_string(),
},
],
default_reasoning_effort: ReasoningEffort::Medium,
is_default: false,
},
Model {
id: "gpt-5.1-codex-mini".to_string(),
model: "gpt-5.1-codex-mini".to_string(),
display_name: "gpt-5.1-codex-mini".to_string(),
description: "Optimized for codex. Cheaper, faster, but less capable.".to_string(),
supported_reasoning_efforts: vec![
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Medium,
description: "Dynamically adjusts reasoning based on the task".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex or ambiguous problems"
.to_string(),
},
],
default_reasoning_effort: ReasoningEffort::Medium,
is_default: false,
},
Model {
id: "gpt-5.2".to_string(),
model: "gpt-5.2".to_string(),
@@ -69,7 +149,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex or ambiguous problems"
description: "Greater reasoning depth for complex or ambiguous problems"
.to_string(),
},
ReasoningEffortOption {
@@ -78,17 +158,25 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
},
],
default_reasoning_effort: ReasoningEffort::Medium,
is_default: true,
is_default: false,
},
Model {
id: "gpt-5.1-codex-mini".to_string(),
model: "gpt-5.1-codex-mini".to_string(),
display_name: "gpt-5.1-codex-mini".to_string(),
description: "Optimized for codex. Cheaper, faster, but less capable.".to_string(),
id: "gpt-5.1".to_string(),
model: "gpt-5.1".to_string(),
display_name: "gpt-5.1".to_string(),
description: "Broad world knowledge with strong general reasoning.".to_string(),
supported_reasoning_efforts: vec![
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Low,
description: "Balances speed with some reasoning; useful for straightforward \
queries and short explanations"
.to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Medium,
description: "Dynamically adjusts reasoning based on the task".to_string(),
description: "Provides a solid balance of reasoning depth and latency for \
general-purpose tasks"
.to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::High,
@@ -99,188 +187,5 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
default_reasoning_effort: ReasoningEffort::Medium,
is_default: false,
},
Model {
id: "gpt-5.1-codex-max".to_string(),
model: "gpt-5.1-codex-max".to_string(),
display_name: "gpt-5.1-codex-max".to_string(),
description: "Codex-optimized flagship for deep and fast reasoning.".to_string(),
supported_reasoning_efforts: vec![
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Low,
description: "Fast responses with lighter reasoning".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Medium,
description: "Balances speed and reasoning depth for everyday tasks"
.to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::High,
description: "Greater reasoning depth for complex problems".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::XHigh,
description: "Extra high reasoning depth for complex problems".to_string(),
},
],
default_reasoning_effort: ReasoningEffort::Medium,
is_default: false,
},
Model {
id: "gpt-5.2-codex".to_string(),
model: "gpt-5.2-codex".to_string(),
display_name: "gpt-5.2-codex".to_string(),
description: "Latest frontier agentic coding model.".to_string(),
supported_reasoning_efforts: vec![
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Low,
description: "Fast responses with lighter reasoning".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Medium,
description: "Balances speed and reasoning depth for everyday tasks"
.to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::High,
description: "Greater reasoning depth for complex problems".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::XHigh,
description: "Extra high reasoning depth for complex problems".to_string(),
},
],
default_reasoning_effort: ReasoningEffort::Medium,
is_default: false,
},
];
assert_eq!(items, expected_models);
assert!(next_cursor.is_none());
Ok(())
}
#[tokio::test]
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?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let first_request = mcp
.send_list_models_request(ModelListParams {
limit: Some(1),
cursor: None,
})
.await?;
let first_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(first_request)),
)
.await??;
let ModelListResponse {
data: first_items,
next_cursor: first_cursor,
} = to_response::<ModelListResponse>(first_response)?;
assert_eq!(first_items.len(), 1);
assert_eq!(first_items[0].id, "gpt-5.2");
let next_cursor = first_cursor.ok_or_else(|| anyhow!("cursor for second page"))?;
let second_request = mcp
.send_list_models_request(ModelListParams {
limit: Some(1),
cursor: Some(next_cursor.clone()),
})
.await?;
let second_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(second_request)),
)
.await??;
let ModelListResponse {
data: second_items,
next_cursor: second_cursor,
} = to_response::<ModelListResponse>(second_response)?;
assert_eq!(second_items.len(), 1);
assert_eq!(second_items[0].id, "gpt-5.1-codex-mini");
let third_cursor = second_cursor.ok_or_else(|| anyhow!("cursor for third page"))?;
let third_request = mcp
.send_list_models_request(ModelListParams {
limit: Some(1),
cursor: Some(third_cursor.clone()),
})
.await?;
let third_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(third_request)),
)
.await??;
let ModelListResponse {
data: third_items,
next_cursor: third_cursor,
} = to_response::<ModelListResponse>(third_response)?;
assert_eq!(third_items.len(), 1);
assert_eq!(third_items[0].id, "gpt-5.1-codex-max");
let fourth_cursor = third_cursor.ok_or_else(|| anyhow!("cursor for fourth page"))?;
let fourth_request = mcp
.send_list_models_request(ModelListParams {
limit: Some(1),
cursor: Some(fourth_cursor.clone()),
})
.await?;
let fourth_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(fourth_request)),
)
.await??;
let ModelListResponse {
data: fourth_items,
next_cursor: fourth_cursor,
} = to_response::<ModelListResponse>(fourth_response)?;
assert_eq!(fourth_items.len(), 1);
assert_eq!(fourth_items[0].id, "gpt-5.2-codex");
assert!(fourth_cursor.is_none());
Ok(())
}
#[tokio::test]
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?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_list_models_request(ModelListParams {
limit: None,
cursor: Some("invalid".to_string()),
})
.await?;
let error: JSONRPCError = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.id, RequestId::Integer(request_id));
assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE);
assert_eq!(error.error.message, "invalid cursor: invalid");
Ok(())
]
}

View File

@@ -1,231 +0,0 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
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 core_test_support::responses;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn turn_start_accepts_output_schema_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let body = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let response_mock = responses::mount_sse_once(&server, body).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 thread_req = mcp
.send_thread_start_request(ThreadStartParams {
..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::<ThreadStartResponse>(thread_resp)?;
let output_schema = serde_json::json!({
"type": "object",
"properties": {
"answer": { "type": "string" }
},
"required": ["answer"],
"additionalProperties": false
});
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
}],
output_schema: Some(output_schema.clone()),
..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::<TurnStartResponse>(turn_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let request = response_mock.single_request();
let payload = request.body_json();
let text = payload.get("text").expect("request missing text field");
let format = text
.get("format")
.expect("request missing text.format field");
assert_eq!(
format,
&serde_json::json!({
"name": "codex_output_schema",
"type": "json_schema",
"strict": true,
"schema": output_schema,
})
);
Ok(())
}
#[tokio::test]
async fn turn_start_output_schema_is_per_turn_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let body1 = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let response_mock1 = responses::mount_sse_once(&server, body1).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 thread_req = mcp
.send_thread_start_request(ThreadStartParams {
..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::<ThreadStartResponse>(thread_resp)?;
let output_schema = serde_json::json!({
"type": "object",
"properties": {
"answer": { "type": "string" }
},
"required": ["answer"],
"additionalProperties": false
});
let turn_req_1 = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
}],
output_schema: Some(output_schema.clone()),
..Default::default()
})
.await?;
let turn_resp_1: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req_1)),
)
.await??;
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp_1)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let payload1 = response_mock1.single_request().body_json();
assert_eq!(
payload1.pointer("/text/format"),
Some(&serde_json::json!({
"name": "codex_output_schema",
"type": "json_schema",
"strict": true,
"schema": output_schema,
}))
);
let body2 = responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_assistant_message("msg-2", "Done"),
responses::ev_completed("resp-2"),
]);
let response_mock2 = responses::mount_sse_once(&server, body2).await;
let turn_req_2 = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "Hello again".to_string(),
}],
output_schema: None,
..Default::default()
})
.await?;
let turn_resp_2: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req_2)),
)
.await??;
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp_2)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let payload2 = response_mock2.single_request().body_json();
assert_eq!(payload2.pointer("/text/format"), None);
Ok(())
}
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
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
"#
),
)
}

View File

@@ -540,7 +540,6 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
model: Some("mock-model".to_string()),
effort: Some(ReasoningEffort::Medium),
summary: Some(ReasoningSummary::Auto),
output_schema: None,
})
.await?;
timeout(
@@ -567,7 +566,6 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
model: Some("mock-model".to_string()),
effort: Some(ReasoningEffort::Medium),
summary: Some(ReasoningSummary::Auto),
output_schema: None,
})
.await?;
timeout(

View File

@@ -25,6 +25,5 @@ tree-sitter-bash = { workspace = true }
[dev-dependencies]
assert_cmd = { workspace = true }
assert_matches = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }

View File

@@ -367,447 +367,3 @@ fn extract_apply_patch_from_bash(
Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch)
}
#[cfg(test)]
mod tests {
use super::*;
use assert_matches::assert_matches;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::PathBuf;
use std::string::ToString;
use tempfile::tempdir;
/// Helper to construct a patch with the given body.
fn wrap_patch(body: &str) -> String {
format!("*** Begin Patch\n{body}\n*** End Patch")
}
fn strs_to_strings(strs: &[&str]) -> Vec<String> {
strs.iter().map(ToString::to_string).collect()
}
// Test helpers to reduce repetition when building bash -lc heredoc scripts
fn args_bash(script: &str) -> Vec<String> {
strs_to_strings(&["bash", "-lc", script])
}
fn args_powershell(script: &str) -> Vec<String> {
strs_to_strings(&["powershell.exe", "-Command", script])
}
fn args_powershell_no_profile(script: &str) -> Vec<String> {
strs_to_strings(&["powershell.exe", "-NoProfile", "-Command", script])
}
fn args_pwsh(script: &str) -> Vec<String> {
strs_to_strings(&["pwsh", "-NoProfile", "-Command", script])
}
fn args_cmd(script: &str) -> Vec<String> {
strs_to_strings(&["cmd.exe", "/c", script])
}
fn heredoc_script(prefix: &str) -> String {
format!(
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"
)
}
fn heredoc_script_ps(prefix: &str, suffix: &str) -> String {
format!(
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}"
)
}
fn expected_single_add() -> Vec<Hunk> {
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string(),
}]
}
fn assert_match_args(args: Vec<String>, expected_workdir: Option<&str>) {
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
assert_eq!(workdir.as_deref(), expected_workdir);
assert_eq!(hunks, expected_single_add());
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
fn assert_match(script: &str, expected_workdir: Option<&str>) {
let args = args_bash(script);
assert_match_args(args, expected_workdir);
}
fn assert_not_match(script: &str) {
let args = args_bash(script);
assert_matches!(
maybe_parse_apply_patch(&args),
MaybeApplyPatch::NotApplyPatch
);
}
#[test]
fn test_implicit_patch_single_arg_is_error() {
let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string();
let args = vec![patch];
let dir = tempdir().unwrap();
assert_matches!(
maybe_parse_apply_patch_verified(&args, dir.path()),
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
);
}
#[test]
fn test_implicit_patch_bash_script_is_error() {
let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch";
let args = args_bash(script);
let dir = tempdir().unwrap();
assert_matches!(
maybe_parse_apply_patch_verified(&args, dir.path()),
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
);
}
#[test]
fn test_literal() {
let args = strs_to_strings(&[
"apply_patch",
r#"*** Begin Patch
*** Add File: foo
+hi
*** End Patch
"#,
]);
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
assert_eq!(
hunks,
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string()
}]
);
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
#[test]
fn test_literal_applypatch() {
let args = strs_to_strings(&[
"applypatch",
r#"*** Begin Patch
*** Add File: foo
+hi
*** End Patch
"#,
]);
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
assert_eq!(
hunks,
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string()
}]
);
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
#[test]
fn test_heredoc() {
assert_match(&heredoc_script(""), None);
}
#[test]
fn test_heredoc_non_login_shell() {
let script = heredoc_script("");
let args = strs_to_strings(&["bash", "-c", &script]);
assert_match_args(args, None);
}
#[test]
fn test_heredoc_applypatch() {
let args = strs_to_strings(&[
"bash",
"-lc",
r#"applypatch <<'PATCH'
*** Begin Patch
*** Add File: foo
+hi
*** End Patch
PATCH"#,
]);
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
assert_eq!(workdir, None);
assert_eq!(
hunks,
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string()
}]
);
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
#[test]
fn test_powershell_heredoc() {
let script = heredoc_script("");
assert_match_args(args_powershell(&script), None);
}
#[test]
fn test_powershell_heredoc_no_profile() {
let script = heredoc_script("");
assert_match_args(args_powershell_no_profile(&script), None);
}
#[test]
fn test_pwsh_heredoc() {
let script = heredoc_script("");
assert_match_args(args_pwsh(&script), None);
}
#[test]
fn test_cmd_heredoc_with_cd() {
let script = heredoc_script("cd foo && ");
assert_match_args(args_cmd(&script), Some("foo"));
}
#[test]
fn test_heredoc_with_leading_cd() {
assert_match(&heredoc_script("cd foo && "), Some("foo"));
}
#[test]
fn test_cd_with_semicolon_is_ignored() {
assert_not_match(&heredoc_script("cd foo; "));
}
#[test]
fn test_cd_or_apply_patch_is_ignored() {
assert_not_match(&heredoc_script("cd bar || "));
}
#[test]
fn test_cd_pipe_apply_patch_is_ignored() {
assert_not_match(&heredoc_script("cd bar | "));
}
#[test]
fn test_cd_single_quoted_path_with_spaces() {
assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar"));
}
#[test]
fn test_cd_double_quoted_path_with_spaces() {
assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar"));
}
#[test]
fn test_echo_and_apply_patch_is_ignored() {
assert_not_match(&heredoc_script("echo foo && "));
}
#[test]
fn test_apply_patch_with_arg_is_ignored() {
let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH";
assert_not_match(script);
}
#[test]
fn test_double_cd_then_apply_patch_is_ignored() {
assert_not_match(&heredoc_script("cd foo && cd bar && "));
}
#[test]
fn test_cd_two_args_is_ignored() {
assert_not_match(&heredoc_script("cd foo bar && "));
}
#[test]
fn test_cd_then_apply_patch_then_extra_is_ignored() {
let script = heredoc_script_ps("cd bar && ", " && echo done");
assert_not_match(&script);
}
#[test]
fn test_echo_then_cd_and_apply_patch_is_ignored() {
// Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match.
assert_not_match(&heredoc_script("echo foo; cd bar && "));
}
#[test]
fn test_unified_diff_last_line_replacement() {
// Replace the very last line of the file.
let dir = tempdir().unwrap();
let path = dir.path().join("last.txt");
fs::write(&path, "foo\nbar\nbaz\n").unwrap();
let patch = wrap_patch(&format!(
r#"*** Update File: {}
@@
foo
bar
-baz
+BAZ
"#,
path.display()
));
let patch = parse_patch(&patch).unwrap();
let chunks = match patch.hunks.as_slice() {
[Hunk::UpdateFile { chunks, .. }] => chunks,
_ => panic!("Expected a single UpdateFile hunk"),
};
let diff = unified_diff_from_chunks(&path, chunks).unwrap();
let expected_diff = r#"@@ -2,2 +2,2 @@
bar
-baz
+BAZ
"#;
let expected = ApplyPatchFileUpdate {
unified_diff: expected_diff.to_string(),
content: "foo\nbar\nBAZ\n".to_string(),
};
assert_eq!(expected, diff);
}
#[test]
fn test_unified_diff_insert_at_eof() {
// Insert a new line at endoffile.
let dir = tempdir().unwrap();
let path = dir.path().join("insert.txt");
fs::write(&path, "foo\nbar\nbaz\n").unwrap();
let patch = wrap_patch(&format!(
r#"*** Update File: {}
@@
+quux
*** End of File
"#,
path.display()
));
let patch = parse_patch(&patch).unwrap();
let chunks = match patch.hunks.as_slice() {
[Hunk::UpdateFile { chunks, .. }] => chunks,
_ => panic!("Expected a single UpdateFile hunk"),
};
let diff = unified_diff_from_chunks(&path, chunks).unwrap();
let expected_diff = r#"@@ -3 +3,2 @@
baz
+quux
"#;
let expected = ApplyPatchFileUpdate {
unified_diff: expected_diff.to_string(),
content: "foo\nbar\nbaz\nquux\n".to_string(),
};
assert_eq!(expected, diff);
}
#[test]
fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
let session_dir = tempdir().unwrap();
let relative_path = "source.txt";
// Note that we need this file to exist for the patch to be "verified"
// and parsed correctly.
let session_file_path = session_dir.path().join(relative_path);
fs::write(&session_file_path, "session directory content\n").unwrap();
let argv = vec![
"apply_patch".to_string(),
r#"*** Begin Patch
*** Update File: source.txt
@@
-session directory content
+updated session directory content
*** End Patch"#
.to_string(),
];
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
// Verify the patch contents - as otherwise we may have pulled contents
// from the wrong file (as we're using relative paths)
assert_eq!(
result,
MaybeApplyPatchVerified::Body(ApplyPatchAction {
changes: HashMap::from([(
session_dir.path().join(relative_path),
ApplyPatchFileChange::Update {
unified_diff: r#"@@ -1 +1 @@
-session directory content
+updated session directory content
"#
.to_string(),
move_path: None,
new_content: "updated session directory content\n".to_string(),
},
)]),
patch: argv[1].clone(),
cwd: session_dir.path().to_path_buf(),
})
);
}
#[test]
fn test_apply_patch_resolves_move_path_with_effective_cwd() {
let session_dir = tempdir().unwrap();
let worktree_rel = "alt";
let worktree_dir = session_dir.path().join(worktree_rel);
fs::create_dir_all(&worktree_dir).unwrap();
let source_name = "old.txt";
let dest_name = "renamed.txt";
let source_path = worktree_dir.join(source_name);
fs::write(&source_path, "before\n").unwrap();
let patch = wrap_patch(&format!(
r#"*** Update File: {source_name}
*** Move to: {dest_name}
@@
-before
+after"#
));
let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH");
let argv = vec!["bash".into(), "-lc".into(), shell_script];
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
let action = match result {
MaybeApplyPatchVerified::Body(action) => action,
other => panic!("expected verified body, got {other:?}"),
};
assert_eq!(action.cwd, worktree_dir);
let change = action
.changes()
.get(&worktree_dir.join(source_name))
.expect("source file change present");
match change {
ApplyPatchFileChange::Update { move_path, .. } => {
assert_eq!(
move_path.as_deref(),
Some(worktree_dir.join(dest_name).as_path())
);
}
other => panic!("expected update change, got {other:?}"),
}
}
}

View File

@@ -544,7 +544,11 @@ pub fn print_summary(
#[cfg(test)]
mod tests {
use crate::invocation::MaybeApplyPatch;
use crate::invocation::maybe_parse_apply_patch;
use super::*;
use assert_matches::assert_matches;
use pretty_assertions::assert_eq;
use std::fs;
use std::string::ToString;
@@ -555,6 +559,270 @@ mod tests {
format!("*** Begin Patch\n{body}\n*** End Patch")
}
fn strs_to_strings(strs: &[&str]) -> Vec<String> {
strs.iter().map(ToString::to_string).collect()
}
// Test helpers to reduce repetition when building bash -lc heredoc scripts
fn args_bash(script: &str) -> Vec<String> {
strs_to_strings(&["bash", "-lc", script])
}
fn args_powershell(script: &str) -> Vec<String> {
strs_to_strings(&["powershell.exe", "-Command", script])
}
fn args_powershell_no_profile(script: &str) -> Vec<String> {
strs_to_strings(&["powershell.exe", "-NoProfile", "-Command", script])
}
fn args_pwsh(script: &str) -> Vec<String> {
strs_to_strings(&["pwsh", "-NoProfile", "-Command", script])
}
fn args_cmd(script: &str) -> Vec<String> {
strs_to_strings(&["cmd.exe", "/c", script])
}
fn heredoc_script(prefix: &str) -> String {
format!(
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"
)
}
fn heredoc_script_ps(prefix: &str, suffix: &str) -> String {
format!(
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}"
)
}
fn expected_single_add() -> Vec<Hunk> {
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string(),
}]
}
fn assert_match_args(args: Vec<String>, expected_workdir: Option<&str>) {
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
assert_eq!(workdir.as_deref(), expected_workdir);
assert_eq!(hunks, expected_single_add());
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
fn assert_match(script: &str, expected_workdir: Option<&str>) {
let args = args_bash(script);
assert_match_args(args, expected_workdir);
}
fn assert_not_match(script: &str) {
let args = args_bash(script);
assert_matches!(
maybe_parse_apply_patch(&args),
MaybeApplyPatch::NotApplyPatch
);
}
#[test]
fn test_implicit_patch_single_arg_is_error() {
let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string();
let args = vec![patch];
let dir = tempdir().unwrap();
assert_matches!(
maybe_parse_apply_patch_verified(&args, dir.path()),
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
);
}
#[test]
fn test_implicit_patch_bash_script_is_error() {
let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch";
let args = args_bash(script);
let dir = tempdir().unwrap();
assert_matches!(
maybe_parse_apply_patch_verified(&args, dir.path()),
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
);
}
#[test]
fn test_literal() {
let args = strs_to_strings(&[
"apply_patch",
r#"*** Begin Patch
*** Add File: foo
+hi
*** End Patch
"#,
]);
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
assert_eq!(
hunks,
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string()
}]
);
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
#[test]
fn test_literal_applypatch() {
let args = strs_to_strings(&[
"applypatch",
r#"*** Begin Patch
*** Add File: foo
+hi
*** End Patch
"#,
]);
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
assert_eq!(
hunks,
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string()
}]
);
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
#[test]
fn test_heredoc() {
assert_match(&heredoc_script(""), None);
}
#[test]
fn test_heredoc_non_login_shell() {
let script = heredoc_script("");
let args = strs_to_strings(&["bash", "-c", &script]);
assert_match_args(args, None);
}
#[test]
fn test_heredoc_applypatch() {
let args = strs_to_strings(&[
"bash",
"-lc",
r#"applypatch <<'PATCH'
*** Begin Patch
*** Add File: foo
+hi
*** End Patch
PATCH"#,
]);
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
assert_eq!(workdir, None);
assert_eq!(
hunks,
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string()
}]
);
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
#[test]
fn test_powershell_heredoc() {
let script = heredoc_script("");
assert_match_args(args_powershell(&script), None);
}
#[test]
fn test_powershell_heredoc_no_profile() {
let script = heredoc_script("");
assert_match_args(args_powershell_no_profile(&script), None);
}
#[test]
fn test_pwsh_heredoc() {
let script = heredoc_script("");
assert_match_args(args_pwsh(&script), None);
}
#[test]
fn test_cmd_heredoc_with_cd() {
let script = heredoc_script("cd foo && ");
assert_match_args(args_cmd(&script), Some("foo"));
}
#[test]
fn test_heredoc_with_leading_cd() {
assert_match(&heredoc_script("cd foo && "), Some("foo"));
}
#[test]
fn test_cd_with_semicolon_is_ignored() {
assert_not_match(&heredoc_script("cd foo; "));
}
#[test]
fn test_cd_or_apply_patch_is_ignored() {
assert_not_match(&heredoc_script("cd bar || "));
}
#[test]
fn test_cd_pipe_apply_patch_is_ignored() {
assert_not_match(&heredoc_script("cd bar | "));
}
#[test]
fn test_cd_single_quoted_path_with_spaces() {
assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar"));
}
#[test]
fn test_cd_double_quoted_path_with_spaces() {
assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar"));
}
#[test]
fn test_echo_and_apply_patch_is_ignored() {
assert_not_match(&heredoc_script("echo foo && "));
}
#[test]
fn test_apply_patch_with_arg_is_ignored() {
let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH";
assert_not_match(script);
}
#[test]
fn test_double_cd_then_apply_patch_is_ignored() {
assert_not_match(&heredoc_script("cd foo && cd bar && "));
}
#[test]
fn test_cd_two_args_is_ignored() {
assert_not_match(&heredoc_script("cd foo bar && "));
}
#[test]
fn test_cd_then_apply_patch_then_extra_is_ignored() {
let script = heredoc_script_ps("cd bar && ", " && echo done");
assert_not_match(&script);
}
#[test]
fn test_echo_then_cd_and_apply_patch_is_ignored() {
// Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match.
assert_not_match(&heredoc_script("echo foo; cd bar && "));
}
#[test]
fn test_add_file_hunk_creates_file_with_contents() {
let dir = tempdir().unwrap();
@@ -1043,6 +1311,99 @@ g
);
}
#[test]
fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
let session_dir = tempdir().unwrap();
let relative_path = "source.txt";
// Note that we need this file to exist for the patch to be "verified"
// and parsed correctly.
let session_file_path = session_dir.path().join(relative_path);
fs::write(&session_file_path, "session directory content\n").unwrap();
let argv = vec![
"apply_patch".to_string(),
r#"*** Begin Patch
*** Update File: source.txt
@@
-session directory content
+updated session directory content
*** End Patch"#
.to_string(),
];
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
// Verify the patch contents - as otherwise we may have pulled contents
// from the wrong file (as we're using relative paths)
assert_eq!(
result,
MaybeApplyPatchVerified::Body(ApplyPatchAction {
changes: HashMap::from([(
session_dir.path().join(relative_path),
ApplyPatchFileChange::Update {
unified_diff: r#"@@ -1 +1 @@
-session directory content
+updated session directory content
"#
.to_string(),
move_path: None,
new_content: "updated session directory content\n".to_string(),
},
)]),
patch: argv[1].clone(),
cwd: session_dir.path().to_path_buf(),
})
);
}
#[test]
fn test_apply_patch_resolves_move_path_with_effective_cwd() {
let session_dir = tempdir().unwrap();
let worktree_rel = "alt";
let worktree_dir = session_dir.path().join(worktree_rel);
fs::create_dir_all(&worktree_dir).unwrap();
let source_name = "old.txt";
let dest_name = "renamed.txt";
let source_path = worktree_dir.join(source_name);
fs::write(&source_path, "before\n").unwrap();
let patch = wrap_patch(&format!(
r#"*** Update File: {source_name}
*** Move to: {dest_name}
@@
-before
+after"#
));
let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH");
let argv = vec!["bash".into(), "-lc".into(), shell_script];
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
let action = match result {
MaybeApplyPatchVerified::Body(action) => action,
other => panic!("expected verified body, got {other:?}"),
};
assert_eq!(action.cwd, worktree_dir);
let change = action
.changes()
.get(&worktree_dir.join(source_name))
.expect("source file change present");
match change {
ApplyPatchFileChange::Update { move_path, .. } => {
assert_eq!(
move_path.as_deref(),
Some(worktree_dir.join(dest_name).as_path())
);
}
other => panic!("expected update change, got {other:?}"),
}
}
#[test]
fn test_apply_patch_fails_on_write_error() {
let dir = tempdir().unwrap();

View File

@@ -1,3 +0,0 @@
line1
naïve café ✅
line3

View File

@@ -1,3 +0,0 @@
line1
naïve café
line3

View File

@@ -1,7 +0,0 @@
*** Begin Patch
*** Update File: foo.txt
@@
line1
-naïve café
+naïve café ✅
*** End Patch

View File

@@ -1,13 +1,8 @@
use assert_cmd::Command;
use assert_cmd::prelude::*;
use std::fs;
use std::process::Command;
use tempfile::tempdir;
fn apply_patch_command() -> anyhow::Result<Command> {
Ok(Command::new(codex_utils_cargo_bin::cargo_bin(
"apply_patch",
)?))
}
#[test]
fn test_apply_patch_cli_add_and_update() -> anyhow::Result<()> {
let tmp = tempdir()?;
@@ -21,7 +16,8 @@ fn test_apply_patch_cli_add_and_update() -> anyhow::Result<()> {
+hello
*** End Patch"#
);
apply_patch_command()?
Command::cargo_bin("apply_patch")
.expect("should find apply_patch binary")
.arg(add_patch)
.current_dir(tmp.path())
.assert()
@@ -38,7 +34,8 @@ fn test_apply_patch_cli_add_and_update() -> anyhow::Result<()> {
+world
*** End Patch"#
);
apply_patch_command()?
Command::cargo_bin("apply_patch")
.expect("should find apply_patch binary")
.arg(update_patch)
.current_dir(tmp.path())
.assert()
@@ -62,9 +59,10 @@ fn test_apply_patch_cli_stdin_add_and_update() -> anyhow::Result<()> {
+hello
*** End Patch"#
);
apply_patch_command()?
.current_dir(tmp.path())
.write_stdin(add_patch)
let mut cmd =
assert_cmd::Command::cargo_bin("apply_patch").expect("should find apply_patch binary");
cmd.current_dir(tmp.path());
cmd.write_stdin(add_patch)
.assert()
.success()
.stdout(format!("Success. Updated the following files:\nA {file}\n"));
@@ -79,9 +77,10 @@ fn test_apply_patch_cli_stdin_add_and_update() -> anyhow::Result<()> {
+world
*** End Patch"#
);
apply_patch_command()?
.current_dir(tmp.path())
.write_stdin(update_patch)
let mut cmd =
assert_cmd::Command::cargo_bin("apply_patch").expect("should find apply_patch binary");
cmd.current_dir(tmp.path());
cmd.write_stdin(update_patch)
.assert()
.success()
.stdout(format!("Success. Updated the following files:\nM {file}\n"));

View File

@@ -1,3 +1,4 @@
use assert_cmd::prelude::*;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::fs;
@@ -8,8 +9,7 @@ use tempfile::tempdir;
#[test]
fn test_apply_patch_scenarios() -> anyhow::Result<()> {
let scenarios_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/scenarios");
for scenario in fs::read_dir(scenarios_dir)? {
for scenario in fs::read_dir("tests/fixtures/scenarios")? {
let scenario = scenario?;
let path = scenario.path();
if path.is_dir() {
@@ -36,7 +36,7 @@ fn run_apply_patch_scenario(dir: &Path) -> anyhow::Result<()> {
// Run apply_patch in the temporary directory. We intentionally do not assert
// on the exit status here; the scenarios are specified purely in terms of
// final filesystem state, which we compare below.
Command::new(codex_utils_cargo_bin::cargo_bin("apply_patch")?)
Command::cargo_bin("apply_patch")?
.arg(patch)
.current_dir(tmp.path())
.output()?;
@@ -82,15 +82,11 @@ fn snapshot_dir_recursive(
continue;
};
let rel = stripped.to_path_buf();
// Under Buck2, files in `__srcs` are often materialized as symlinks.
// Use `metadata()` (follows symlinks) so our fixture snapshots work
// under both Cargo and Buck2.
let metadata = fs::metadata(&path)?;
if metadata.is_dir() {
let file_type = entry.file_type()?;
if file_type.is_dir() {
entries.insert(rel.clone(), Entry::Dir);
snapshot_dir_recursive(base, &path, entries)?;
} else if metadata.is_file() {
} else if file_type.is_file() {
let contents = fs::read(&path)?;
entries.insert(rel, Entry::File(contents));
}
@@ -102,14 +98,12 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> {
for entry in fs::read_dir(src)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
let dest_path = dst.join(entry.file_name());
// See note in `snapshot_dir_recursive` about Buck2 symlink trees.
let metadata = fs::metadata(&path)?;
if metadata.is_dir() {
if file_type.is_dir() {
fs::create_dir_all(&dest_path)?;
copy_dir_recursive(&path, &dest_path)?;
} else if metadata.is_file() {
} else if file_type.is_file() {
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}

View File

@@ -5,13 +5,13 @@ use std::path::Path;
use tempfile::tempdir;
fn run_apply_patch_in_dir(dir: &Path, patch: &str) -> anyhow::Result<assert_cmd::assert::Assert> {
let mut cmd = Command::new(codex_utils_cargo_bin::cargo_bin("apply_patch")?);
let mut cmd = Command::cargo_bin("apply_patch")?;
cmd.current_dir(dir);
Ok(cmd.arg(patch).assert())
}
fn apply_patch_command(dir: &Path) -> anyhow::Result<Command> {
let mut cmd = Command::new(codex_utils_cargo_bin::cargo_bin("apply_patch")?);
let mut cmd = Command::cargo_bin("apply_patch")?;
cmd.current_dir(dir);
Ok(cmd)
}

View File

@@ -3,6 +3,7 @@ use std::path::PathBuf;
use clap::Parser;
use codex_common::CliConfigOverrides;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use crate::chatgpt_token::init_chatgpt_token_from_auth;
use crate::get_task::GetTaskResponse;
@@ -27,6 +28,7 @@ pub async fn run_apply_command(
.config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?,
ConfigOverrides::default(),
)
.await?;

View File

@@ -37,13 +37,13 @@ codex-rmcp-client = { workspace = true }
codex-stdio-to-uds = { workspace = true }
codex-tui = { workspace = true }
codex-tui2 = { workspace = true }
codex-utils-absolute-path = { workspace = true }
ctor = { workspace = true }
libc = { workspace = true }
owo-colors = { workspace = true }
regex-lite = { workspace = true }
regex-lite = { workspace = true}
serde_json = { workspace = true }
supports-color = { workspace = true }
toml = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"macros",
@@ -51,7 +51,6 @@ tokio = { workspace = true, features = [
"rt-multi-thread",
"signal",
] }
toml = { workspace = true }
tracing = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
@@ -60,7 +59,6 @@ codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows-
[dev-dependencies]
assert_cmd = { workspace = true }
assert_matches = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
predicates = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }

View File

@@ -109,7 +109,7 @@ async fn run_command_under_sandbox(
log_denials: bool,
) -> anyhow::Result<()> {
let sandbox_mode = create_sandbox_mode(full_auto);
let config = Config::load_with_cli_overrides_and_harness_overrides(
let config = Config::load_with_cli_overrides(
config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?,
@@ -140,7 +140,7 @@ async fn run_command_under_sandbox(
use codex_windows_sandbox::run_windows_sandbox_capture;
use codex_windows_sandbox::run_windows_sandbox_capture_elevated;
let policy_str = serde_json::to_string(config.sandbox_policy.get())?;
let policy_str = serde_json::to_string(&config.sandbox_policy)?;
let sandbox_cwd = sandbox_policy_cwd.clone();
let cwd_clone = cwd.clone();
@@ -216,7 +216,7 @@ async fn run_command_under_sandbox(
spawn_command_under_seatbelt(
command,
cwd,
config.sandbox_policy.get(),
&config.sandbox_policy,
sandbox_policy_cwd.as_path(),
stdio_policy,
env,
@@ -232,7 +232,7 @@ async fn run_command_under_sandbox(
codex_linux_sandbox_exe,
command,
cwd,
config.sandbox_policy.get(),
&config.sandbox_policy,
sandbox_policy_cwd.as_path(),
stdio_policy,
env,

View File

@@ -6,6 +6,7 @@ use codex_core::auth::CLIENT_ID;
use codex_core::auth::login_with_api_key;
use codex_core::auth::logout;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_login::ServerOptions;
use codex_login::run_device_code_login;
use codex_login::run_login_server;
@@ -209,7 +210,8 @@ async fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config
}
};
match Config::load_with_cli_overrides(cli_overrides).await {
let config_overrides = ConfigOverrides::default();
match Config::load_with_cli_overrides(cli_overrides, config_overrides).await {
Ok(config) => config,
Err(e) => {
eprintln!("Error loading configuration: {e}");

View File

@@ -44,7 +44,6 @@ use codex_core::features::Feature;
use codex_core::features::FeatureOverrides;
use codex_core::features::Features;
use codex_core::features::is_known_feature_key;
use codex_utils_absolute_path::AbsolutePathBuf;
/// Codex CLI
///
@@ -411,7 +410,7 @@ fn stage_str(stage: codex_core::features::Stage) -> &'static str {
use codex_core::features::Stage;
match stage {
Stage::Experimental => "experimental",
Stage::Beta { .. } => "beta",
Stage::Beta => "beta",
Stage::Stable => "stable",
Stage::Deprecated => "deprecated",
Stage::Removed => "removed",
@@ -632,11 +631,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
..Default::default()
};
let config = Config::load_with_cli_overrides_and_harness_overrides(
cli_kv_overrides,
overrides,
)
.await?;
let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides).await?;
for def in codex_core::features::FEATURES.iter() {
let name = def.key;
let stage = stage_str(def.stage);
@@ -688,13 +683,7 @@ async fn is_tui2_enabled(cli: &TuiCli) -> std::io::Result<bool> {
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
let codex_home = find_codex_home()?;
let cwd = cli.cwd.clone();
let config_cwd = match cwd.as_deref() {
Some(path) => AbsolutePathBuf::from_absolute_path(path)?,
None => AbsolutePathBuf::current_dir()?,
};
let config_toml =
load_config_as_toml_with_cli_overrides(&codex_home, &config_cwd, cli_kv_overrides).await?;
let config_toml = load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides).await?;
let config_profile = config_toml.get_config_profile(cli.config_profile.clone())?;
let overrides = FeatureOverrides::default();
let features = Features::from_config(&config_toml, &config_profile, overrides);

View File

@@ -8,17 +8,21 @@ use clap::ArgGroup;
use codex_common::CliConfigOverrides;
use codex_common::format_env_display::format_env_display;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::find_codex_home;
use codex_core::config::load_global_mcp_servers;
use codex_core::config::types::McpServerConfig;
use codex_core::config::types::McpServerTransportConfig;
use codex_core::features::Feature;
use codex_core::mcp::auth::compute_auth_statuses;
use codex_core::protocol::McpAuthStatus;
use codex_rmcp_client::delete_oauth_tokens;
use codex_rmcp_client::perform_oauth_login;
use codex_rmcp_client::supports_oauth_login;
/// [experimental] Launch Codex as an MCP server or manage configured MCP servers.
///
/// Subcommands:
/// - `serve` — run the MCP server on stdio
/// - `list` — list configured servers (with `--json`)
@@ -36,11 +40,24 @@ pub struct McpCli {
#[derive(Debug, clap::Subcommand)]
pub enum McpSubcommand {
/// [experimental] List configured MCP servers.
List(ListArgs),
/// [experimental] Show details for a configured MCP server.
Get(GetArgs),
/// [experimental] Add a global MCP server entry.
Add(AddArgs),
/// [experimental] Remove a global MCP server entry.
Remove(RemoveArgs),
/// [experimental] Authenticate with a configured MCP server via OAuth.
/// Requires features.rmcp_client = true in config.toml.
Login(LoginArgs),
/// [experimental] Remove stored OAuth credentials for a server.
/// Requires features.rmcp_client = true in config.toml.
Logout(LogoutArgs),
}
@@ -183,7 +200,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides)
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
.await
.context("failed to load configuration")?;
@@ -266,17 +283,24 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
{
match supports_oauth_login(&url).await {
Ok(true) => {
println!("Detected OAuth support. Starting OAuth flow…");
perform_oauth_login(
&name,
&url,
config.mcp_oauth_credentials_store_mode,
http_headers.clone(),
env_http_headers.clone(),
&Vec::new(),
)
.await?;
println!("Successfully logged in.");
if !config.features.enabled(Feature::RmcpClient) {
println!(
"MCP server supports login. Add `features.rmcp_client = true` \
to your config.toml and run `codex mcp login {name}` to login."
);
} else {
println!("Detected OAuth support. Starting OAuth flow…");
perform_oauth_login(
&name,
&url,
config.mcp_oauth_credentials_store_mode,
http_headers.clone(),
env_http_headers.clone(),
&Vec::new(),
)
.await?;
println!("Successfully logged in.");
}
}
Ok(false) => {}
Err(_) => println!(
@@ -325,10 +349,16 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides)
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
.await
.context("failed to load configuration")?;
if !config.features.enabled(Feature::RmcpClient) {
bail!(
"OAuth login is only supported when [features].rmcp_client is true in config.toml. See https://github.com/openai/codex/blob/main/docs/config.md#feature-flags for details."
);
}
let LoginArgs { name, scopes } = login_args;
let Some(server) = config.mcp_servers.get(&name) else {
@@ -362,7 +392,7 @@ async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutAr
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides)
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
.await
.context("failed to load configuration")?;
@@ -391,7 +421,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides)
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
.await
.context("failed to load configuration")?;
@@ -648,7 +678,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides)
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
.await
.context("failed to load configuration")?;

View File

@@ -24,7 +24,7 @@ prefix_rule(
"#,
)?;
let output = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
let output = Command::cargo_bin("codex")?
.env("CODEX_HOME", codex_home.path())
.args([
"execpolicy",

View File

@@ -8,7 +8,7 @@ use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
let mut cmd = assert_cmd::Command::cargo_bin("codex")?;
cmd.env("CODEX_HOME", codex_home);
Ok(cmd)
}

View File

@@ -12,7 +12,7 @@ use serde_json::json;
use tempfile::TempDir;
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
let mut cmd = assert_cmd::Command::cargo_bin("codex")?;
cmd.env("CODEX_HOME", codex_home);
Ok(cmd)
}

View File

@@ -37,9 +37,6 @@ unicode-width = { workspace = true }
owo-colors = { workspace = true, features = ["supports-colors"] }
supports-color = { workspace = true }
[dependencies.async-trait]
workspace = true
[dev-dependencies]
async-trait = { workspace = true }
pretty_assertions = { workspace = true }

View File

@@ -34,6 +34,10 @@ pub struct ExecCommand {
#[arg(long = "env", value_name = "ENV_ID")]
pub environment: String,
/// Git branch to run in Codex Cloud.
#[arg(long = "branch", value_name = "BRANCH", default_value = "main")]
pub branch: String,
/// Number of assistant attempts (best-of-N).
#[arg(
long = "attempts",
@@ -41,10 +45,6 @@ pub struct ExecCommand {
value_parser = parse_attempts
)]
pub attempts: usize,
/// Git branch to run in Codex Cloud (defaults to current branch).
#[arg(long = "branch", value_name = "BRANCH")]
pub branch: Option<String>,
}
fn parse_attempts(input: &str) -> Result<usize, String> {

View File

@@ -104,54 +104,6 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext>
})
}
#[async_trait::async_trait]
trait GitInfoProvider {
async fn default_branch_name(&self, path: &std::path::Path) -> Option<String>;
async fn current_branch_name(&self, path: &std::path::Path) -> Option<String>;
}
struct RealGitInfo;
#[async_trait::async_trait]
impl GitInfoProvider for RealGitInfo {
async fn default_branch_name(&self, path: &std::path::Path) -> Option<String> {
codex_core::git_info::default_branch_name(path).await
}
async fn current_branch_name(&self, path: &std::path::Path) -> Option<String> {
codex_core::git_info::current_branch_name(path).await
}
}
async fn resolve_git_ref(branch_override: Option<&String>) -> String {
resolve_git_ref_with_git_info(branch_override, &RealGitInfo).await
}
async fn resolve_git_ref_with_git_info(
branch_override: Option<&String>,
git_info: &impl GitInfoProvider,
) -> String {
if let Some(branch) = branch_override {
let branch = branch.trim();
if !branch.is_empty() {
return branch.to_string();
}
}
if let Ok(cwd) = std::env::current_dir() {
if let Some(branch) = git_info.current_branch_name(&cwd).await {
branch
} else if let Some(branch) = git_info.default_branch_name(&cwd).await {
branch
} else {
"main".to_string()
}
} else {
"main".to_string()
}
}
async fn run_exec_command(args: crate::cli::ExecCommand) -> anyhow::Result<()> {
let crate::cli::ExecCommand {
query,
@@ -162,12 +114,11 @@ async fn run_exec_command(args: crate::cli::ExecCommand) -> anyhow::Result<()> {
let ctx = init_backend("codex_cloud_tasks_exec").await?;
let prompt = resolve_query_input(query)?;
let env_id = resolve_environment_id(&ctx, &environment).await?;
let git_ref = resolve_git_ref(branch.as_ref()).await;
let created = codex_cloud_tasks_client::CloudBackend::create_task(
&*ctx.backend,
&env_id,
&prompt,
&git_ref,
&branch,
false,
attempts,
)
@@ -1411,7 +1362,17 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
let backend = Arc::clone(&backend);
let best_of_n = page.best_of_n;
tokio::spawn(async move {
let git_ref = resolve_git_ref(None).await;
let git_ref = if let Ok(cwd) = std::env::current_dir() {
if let Some(branch) = codex_core::git_info::default_branch_name(&cwd).await {
branch
} else if let Some(branch) = codex_core::git_info::current_branch_name(&cwd).await {
branch
} else {
"main".to_string()
}
} else {
"main".to_string()
};
let result = codex_cloud_tasks_client::CloudBackend::create_task(&*backend, &env, &text, &git_ref, false, best_of_n).await;
let evt = match result {
@@ -2030,7 +1991,6 @@ fn pretty_lines_from_error(raw: &str) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::resolve_git_ref_with_git_info;
use codex_cloud_tasks_client::DiffSummary;
use codex_cloud_tasks_client::MockClient;
use codex_cloud_tasks_client::TaskId;
@@ -2045,85 +2005,6 @@ mod tests {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
struct StubGitInfo {
default_branch: Option<String>,
current_branch: Option<String>,
}
impl StubGitInfo {
fn new(default_branch: Option<String>, current_branch: Option<String>) -> Self {
Self {
default_branch,
current_branch,
}
}
}
#[async_trait::async_trait]
impl super::GitInfoProvider for StubGitInfo {
async fn default_branch_name(&self, _path: &std::path::Path) -> Option<String> {
self.default_branch.clone()
}
async fn current_branch_name(&self, _path: &std::path::Path) -> Option<String> {
self.current_branch.clone()
}
}
#[tokio::test]
async fn branch_override_is_used_when_provided() {
let git_ref = resolve_git_ref_with_git_info(
Some(&"feature/override".to_string()),
&StubGitInfo::new(None, None),
)
.await;
assert_eq!(git_ref, "feature/override");
}
#[tokio::test]
async fn trims_override_whitespace() {
let git_ref = resolve_git_ref_with_git_info(
Some(&" feature/spaces ".to_string()),
&StubGitInfo::new(None, None),
)
.await;
assert_eq!(git_ref, "feature/spaces");
}
#[tokio::test]
async fn prefers_current_branch_when_available() {
let git_ref = resolve_git_ref_with_git_info(
None,
&StubGitInfo::new(
Some("default-main".to_string()),
Some("feature/current".to_string()),
),
)
.await;
assert_eq!(git_ref, "feature/current");
}
#[tokio::test]
async fn falls_back_to_current_branch_when_default_is_missing() {
let git_ref = resolve_git_ref_with_git_info(
None,
&StubGitInfo::new(None, Some("develop".to_string())),
)
.await;
assert_eq!(git_ref, "develop");
}
#[tokio::test]
async fn falls_back_to_main_when_no_git_info_is_available() {
let git_ref = resolve_git_ref_with_git_info(None, &StubGitInfo::new(None, None)).await;
assert_eq!(git_ref, "main");
}
#[test]
fn format_task_status_lines_with_diff_and_label() {
let now = Utc::now();

View File

@@ -5,6 +5,7 @@ use chrono::Utc;
use reqwest::header::HeaderMap;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_login::AuthManager;
pub fn set_user_agent_suffix(suffix: &str) {
@@ -61,7 +62,9 @@ pub fn extract_chatgpt_account_id(token: &str) -> Option<String> {
pub async fn load_auth_manager() -> Option<AuthManager> {
// TODO: pass in cli overrides once cloud tasks properly support them.
let config = Config::load_with_cli_overrides(Vec::new()).await.ok()?;
let config = Config::load_with_cli_overrides(Vec::new(), ConfigOverrides::default())
.await
.ok()?;
Some(AuthManager::new(
config.codex_home,
false,

View File

@@ -59,7 +59,6 @@ pub enum ResponseEvent {
summary_index: i64,
},
RateLimits(RateLimitSnapshot),
ModelsEtag(String),
}
#[derive(Debug, Serialize, Clone)]

View File

@@ -152,9 +152,6 @@ impl Stream for AggregatedStream {
Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot)))) => {
return Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot))));
}
Poll::Ready(Some(Ok(ResponseEvent::ModelsEtag(etag)))) => {
return Poll::Ready(Some(Ok(ResponseEvent::ModelsEtag(etag))));
}
Poll::Ready(Some(Ok(ResponseEvent::Completed {
response_id,
token_usage,

View File

@@ -5,7 +5,6 @@ use crate::provider::Provider;
use crate::telemetry::run_with_request_telemetry;
use codex_client::HttpTransport;
use codex_client::RequestTelemetry;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelsResponse;
use http::HeaderMap;
use http::Method;
@@ -42,7 +41,7 @@ impl<T: HttpTransport, A: AuthProvider> ModelsClient<T, A> {
&self,
client_version: &str,
extra_headers: HeaderMap,
) -> Result<(Vec<ModelInfo>, Option<String>), ApiError> {
) -> Result<ModelsResponse, ApiError> {
let builder = || {
let mut req = self.provider.build_request(Method::GET, self.path());
req.headers.extend(extra_headers.clone());
@@ -67,7 +66,7 @@ impl<T: HttpTransport, A: AuthProvider> ModelsClient<T, A> {
.and_then(|value| value.to_str().ok())
.map(ToString::to_string);
let ModelsResponse { models } = serde_json::from_slice::<ModelsResponse>(&resp.body)
let ModelsResponse { models, etag } = serde_json::from_slice::<ModelsResponse>(&resp.body)
.map_err(|e| {
ApiError::Stream(format!(
"failed to decode models response: {e}; body: {}",
@@ -75,7 +74,9 @@ impl<T: HttpTransport, A: AuthProvider> ModelsClient<T, A> {
))
})?;
Ok((models, header_etag))
let etag = header_etag.unwrap_or(etag);
Ok(ModelsResponse { models, etag })
}
}
@@ -101,15 +102,16 @@ mod tests {
struct CapturingTransport {
last_request: Arc<Mutex<Option<Request>>>,
body: Arc<ModelsResponse>,
etag: Option<String>,
}
impl Default for CapturingTransport {
fn default() -> Self {
Self {
last_request: Arc::new(Mutex::new(None)),
body: Arc::new(ModelsResponse { models: Vec::new() }),
etag: None,
body: Arc::new(ModelsResponse {
models: Vec::new(),
etag: String::new(),
}),
}
}
}
@@ -120,8 +122,8 @@ mod tests {
*self.last_request.lock().unwrap() = Some(req);
let body = serde_json::to_vec(&*self.body).unwrap();
let mut headers = HeaderMap::new();
if let Some(etag) = &self.etag {
headers.insert(ETAG, etag.parse().unwrap());
if !self.body.etag.is_empty() {
headers.insert(ETAG, self.body.etag.parse().unwrap());
}
Ok(Response {
status: StatusCode::OK,
@@ -164,12 +166,14 @@ mod tests {
#[tokio::test]
async fn appends_client_version_query() {
let response = ModelsResponse { models: Vec::new() };
let response = ModelsResponse {
models: Vec::new(),
etag: String::new(),
};
let transport = CapturingTransport {
last_request: Arc::new(Mutex::new(None)),
body: Arc::new(response),
etag: None,
};
let client = ModelsClient::new(
@@ -178,12 +182,12 @@ mod tests {
DummyAuth,
);
let (models, _) = client
let result = client
.list_models("0.99.0", HeaderMap::new())
.await
.expect("request should succeed");
assert_eq!(models.len(), 0);
assert_eq!(result.models.len(), 0);
let url = transport
.last_request
@@ -223,16 +227,17 @@ mod tests {
"truncation_policy": {"mode": "bytes", "limit": 10_000},
"supports_parallel_tool_calls": false,
"context_window": null,
"reasoning_summary_format": "none",
"experimental_supported_tools": [],
}))
.unwrap(),
],
etag: String::new(),
};
let transport = CapturingTransport {
last_request: Arc::new(Mutex::new(None)),
body: Arc::new(response),
etag: None,
};
let client = ModelsClient::new(
@@ -241,25 +246,27 @@ mod tests {
DummyAuth,
);
let (models, _) = client
let result = client
.list_models("0.99.0", HeaderMap::new())
.await
.expect("request should succeed");
assert_eq!(models.len(), 1);
assert_eq!(models[0].slug, "gpt-test");
assert_eq!(models[0].supported_in_api, true);
assert_eq!(models[0].priority, 1);
assert_eq!(result.models.len(), 1);
assert_eq!(result.models[0].slug, "gpt-test");
assert_eq!(result.models[0].supported_in_api, true);
assert_eq!(result.models[0].priority, 1);
}
#[tokio::test]
async fn list_models_includes_etag() {
let response = ModelsResponse { models: Vec::new() };
let response = ModelsResponse {
models: Vec::new(),
etag: "\"abc\"".to_string(),
};
let transport = CapturingTransport {
last_request: Arc::new(Mutex::new(None)),
body: Arc::new(response),
etag: Some("\"abc\"".to_string()),
};
let client = ModelsClient::new(
@@ -268,12 +275,12 @@ mod tests {
DummyAuth,
);
let (models, etag) = client
let result = client
.list_models("0.1.0", HeaderMap::new())
.await
.expect("request should succeed");
assert_eq!(models.len(), 0);
assert_eq!(etag, Some("\"abc\"".to_string()));
assert_eq!(result.models.len(), 0);
assert_eq!(result.etag, "\"abc\"");
}
}

View File

@@ -32,7 +32,6 @@ pub struct ResponsesOptions {
pub store_override: Option<bool>,
pub conversation_id: Option<String>,
pub session_source: Option<SessionSource>,
pub extra_headers: HeaderMap,
}
impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
@@ -74,7 +73,6 @@ impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
store_override,
conversation_id,
session_source,
extra_headers,
} = options;
let request = ResponsesRequestBuilder::new(model, &prompt.instructions, &prompt.input)
@@ -87,7 +85,6 @@ impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
.conversation(conversation_id)
.session_source(session_source)
.store_override(store_override)
.extra_headers(extra_headers)
.build(self.streaming.provider())?;
self.stream_request(request).await

View File

@@ -204,16 +204,24 @@ impl<'a> ChatRequestBuilder<'a> {
call_id,
..
} => {
let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str);
let tool_call = json!({
"id": call_id,
"type": "function",
"function": {
"name": name,
"arguments": arguments,
}
let mut msg = json!({
"role": "assistant",
"content": null,
"tool_calls": [{
"id": call_id,
"type": "function",
"function": {
"name": name,
"arguments": arguments,
}
}]
});
push_tool_call_message(&mut messages, tool_call, reasoning);
if let Some(reasoning) = reasoning_by_anchor_index.get(&idx)
&& let Some(obj) = msg.as_object_mut()
{
obj.insert("reasoning".to_string(), json!(reasoning));
}
messages.push(msg);
}
ResponseItem::LocalShellCall {
id,
@@ -221,14 +229,22 @@ impl<'a> ChatRequestBuilder<'a> {
status,
action,
} => {
let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str);
let tool_call = json!({
"id": id.clone().unwrap_or_default(),
"type": "local_shell_call",
"status": status,
"action": action,
let mut msg = json!({
"role": "assistant",
"content": null,
"tool_calls": [{
"id": id.clone().unwrap_or_default(),
"type": "local_shell_call",
"status": status,
"action": action,
}]
});
push_tool_call_message(&mut messages, tool_call, reasoning);
if let Some(reasoning) = reasoning_by_anchor_index.get(&idx)
&& let Some(obj) = msg.as_object_mut()
{
obj.insert("reasoning".to_string(), json!(reasoning));
}
messages.push(msg);
}
ResponseItem::FunctionCallOutput { call_id, output } => {
let content_value = if let Some(items) = &output.content_items {
@@ -261,16 +277,18 @@ impl<'a> ChatRequestBuilder<'a> {
input,
status: _,
} => {
let tool_call = json!({
"id": id,
"type": "custom",
"custom": {
"name": name,
"input": input,
}
});
let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str);
push_tool_call_message(&mut messages, tool_call, reasoning);
messages.push(json!({
"role": "assistant",
"content": null,
"tool_calls": [{
"id": id,
"type": "custom",
"custom": {
"name": name,
"input": input,
}
}]
}));
}
ResponseItem::CustomToolCallOutput { call_id, output } => {
messages.push(json!({
@@ -310,50 +328,11 @@ impl<'a> ChatRequestBuilder<'a> {
}
}
fn push_tool_call_message(messages: &mut Vec<Value>, tool_call: Value, reasoning: Option<&str>) {
// Chat Completions requires that tool calls are grouped into a single assistant message
// (with `tool_calls: [...]`) followed by tool role responses.
if let Some(Value::Object(obj)) = messages.last_mut()
&& obj.get("role").and_then(Value::as_str) == Some("assistant")
&& obj.get("content").is_some_and(Value::is_null)
&& let Some(tool_calls) = obj.get_mut("tool_calls").and_then(Value::as_array_mut)
{
tool_calls.push(tool_call);
if let Some(reasoning) = reasoning {
if let Some(Value::String(existing)) = obj.get_mut("reasoning") {
if !existing.is_empty() {
existing.push('\n');
}
existing.push_str(reasoning);
} else {
obj.insert(
"reasoning".to_string(),
Value::String(reasoning.to_string()),
);
}
}
return;
}
let mut msg = json!({
"role": "assistant",
"content": null,
"tool_calls": [tool_call],
});
if let Some(reasoning) = reasoning
&& let Some(obj) = msg.as_object_mut()
{
obj.insert("reasoning".to_string(), json!(reasoning));
}
messages.push(msg);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::RetryConfig;
use crate::provider::WireApi;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use http::HeaderValue;
@@ -406,89 +385,4 @@ mod tests {
Some(&HeaderValue::from_static("review"))
);
}
#[test]
fn groups_consecutive_tool_calls_into_a_single_assistant_message() {
let prompt_input = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "read these".to_string(),
}],
},
ResponseItem::FunctionCall {
id: None,
name: "read_file".to_string(),
arguments: r#"{"path":"a.txt"}"#.to_string(),
call_id: "call-a".to_string(),
},
ResponseItem::FunctionCall {
id: None,
name: "read_file".to_string(),
arguments: r#"{"path":"b.txt"}"#.to_string(),
call_id: "call-b".to_string(),
},
ResponseItem::FunctionCall {
id: None,
name: "read_file".to_string(),
arguments: r#"{"path":"c.txt"}"#.to_string(),
call_id: "call-c".to_string(),
},
ResponseItem::FunctionCallOutput {
call_id: "call-a".to_string(),
output: FunctionCallOutputPayload {
content: "A".to_string(),
..Default::default()
},
},
ResponseItem::FunctionCallOutput {
call_id: "call-b".to_string(),
output: FunctionCallOutputPayload {
content: "B".to_string(),
..Default::default()
},
},
ResponseItem::FunctionCallOutput {
call_id: "call-c".to_string(),
output: FunctionCallOutputPayload {
content: "C".to_string(),
..Default::default()
},
},
];
let req = ChatRequestBuilder::new("gpt-test", "inst", &prompt_input, &[])
.build(&provider())
.expect("request");
let messages = req
.body
.get("messages")
.and_then(|v| v.as_array())
.expect("messages array");
// system + user + assistant(tool_calls=[...]) + 3 tool outputs
assert_eq!(messages.len(), 6);
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[1]["role"], "user");
let tool_calls_msg = &messages[2];
assert_eq!(tool_calls_msg["role"], "assistant");
assert_eq!(tool_calls_msg["content"], serde_json::Value::Null);
let tool_calls = tool_calls_msg["tool_calls"]
.as_array()
.expect("tool_calls array");
assert_eq!(tool_calls.len(), 3);
assert_eq!(tool_calls[0]["id"], "call-a");
assert_eq!(tool_calls[1]["id"], "call-b");
assert_eq!(tool_calls[2]["id"], "call-c");
assert_eq!(messages[3]["role"], "tool");
assert_eq!(messages[3]["tool_call_id"], "call-a");
assert_eq!(messages[4]["role"], "tool");
assert_eq!(messages[4]["tool_call_id"], "call-b");
assert_eq!(messages[5]["role"], "tool");
assert_eq!(messages[5]["tool_call_id"], "call-c");
}
}

View File

@@ -30,21 +30,6 @@ pub(crate) fn spawn_chat_stream(
ResponseStream { rx_event }
}
/// Processes Server-Sent Events from the legacy Chat Completions streaming API.
///
/// The upstream protocol terminates a streaming response with a final sentinel event
/// (`data: [DONE]`). Historically, some of our test stubs have emitted `data: DONE`
/// (without brackets) instead.
///
/// `eventsource_stream` delivers these sentinels as regular events rather than signaling
/// end-of-stream. If we try to parse them as JSON, we log and skip them, then keep
/// polling for more events.
///
/// On servers that keep the HTTP connection open after emitting the sentinel (notably
/// wiremock on Windows), skipping the sentinel means we never emit `ResponseEvent::Completed`.
/// Higher-level workflows/tests that wait for completion before issuing subsequent model
/// calls will then stall, which shows up as "expected N requests, got 1" verification
/// failures in the mock server.
pub async fn process_chat_sse<S>(
stream: S,
tx_event: mpsc::Sender<Result<ResponseEvent, ApiError>>,
@@ -72,31 +57,6 @@ pub async fn process_chat_sse<S>(
let mut reasoning_item: Option<ResponseItem> = None;
let mut completed_sent = false;
async fn flush_and_complete(
tx_event: &mpsc::Sender<Result<ResponseEvent, ApiError>>,
reasoning_item: &mut Option<ResponseItem>,
assistant_item: &mut Option<ResponseItem>,
) {
if let Some(reasoning) = reasoning_item.take() {
let _ = tx_event
.send(Ok(ResponseEvent::OutputItemDone(reasoning)))
.await;
}
if let Some(assistant) = assistant_item.take() {
let _ = tx_event
.send(Ok(ResponseEvent::OutputItemDone(assistant)))
.await;
}
let _ = tx_event
.send(Ok(ResponseEvent::Completed {
response_id: String::new(),
token_usage: None,
}))
.await;
}
loop {
let start = Instant::now();
let response = timeout(idle_timeout, stream.next()).await;
@@ -110,8 +70,24 @@ pub async fn process_chat_sse<S>(
return;
}
Ok(None) => {
if let Some(reasoning) = reasoning_item {
let _ = tx_event
.send(Ok(ResponseEvent::OutputItemDone(reasoning)))
.await;
}
if let Some(assistant) = assistant_item {
let _ = tx_event
.send(Ok(ResponseEvent::OutputItemDone(assistant)))
.await;
}
if !completed_sent {
flush_and_complete(&tx_event, &mut reasoning_item, &mut assistant_item).await;
let _ = tx_event
.send(Ok(ResponseEvent::Completed {
response_id: String::new(),
token_usage: None,
}))
.await;
}
return;
}
@@ -125,25 +101,16 @@ pub async fn process_chat_sse<S>(
trace!("SSE event: {}", sse.data);
let data = sse.data.trim();
if data.is_empty() {
if sse.data.trim().is_empty() {
continue;
}
if data == "[DONE]" || data == "DONE" {
if !completed_sent {
flush_and_complete(&tx_event, &mut reasoning_item, &mut assistant_item).await;
}
return;
}
let value: serde_json::Value = match serde_json::from_str(data) {
let value: serde_json::Value = match serde_json::from_str(&sse.data) {
Ok(val) => val,
Err(err) => {
debug!(
"Failed to parse ChatCompletions SSE event: {err}, data: {}",
data
&sse.data
);
continue;
}
@@ -395,16 +362,6 @@ mod tests {
body
}
/// Regression test: the stream should complete when we see a `[DONE]` sentinel.
///
/// This is important for tests/mocks that don't immediately close the underlying
/// connection after emitting the sentinel.
#[tokio::test]
async fn completes_on_done_sentinel_without_json() {
let events = collect_events("event: message\ndata: [DONE]\n\n").await;
assert_matches!(&events[..], [ResponseEvent::Completed { .. }]);
}
async fn collect_events(body: &str) -> Vec<ResponseEvent> {
let reader = ReaderStream::new(std::io::Cursor::new(body.to_string()))
.map_err(|err| codex_client::TransportError::Network(err.to_string()));

View File

@@ -51,19 +51,11 @@ pub fn spawn_response_stream(
telemetry: Option<Arc<dyn SseTelemetry>>,
) -> ResponseStream {
let rate_limits = parse_rate_limit(&stream_response.headers);
let models_etag = stream_response
.headers
.get("X-Models-Etag")
.and_then(|v| v.to_str().ok())
.map(ToString::to_string);
let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent, ApiError>>(1600);
tokio::spawn(async move {
if let Some(snapshot) = rate_limits {
let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await;
}
if let Some(etag) = models_etag {
let _ = tx_event.send(Ok(ResponseEvent::ModelsEtag(etag))).await;
}
process_sse(stream_response.bytes, tx_event, idle_timeout, telemetry).await;
});

View File

@@ -4,12 +4,14 @@ use codex_api::provider::Provider;
use codex_api::provider::RetryConfig;
use codex_api::provider::WireApi;
use codex_client::ReqwestTransport;
use codex_protocol::openai_models::ClientVersion;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::openai_models::ReasoningSummaryFormat;
use codex_protocol::openai_models::TruncationPolicyConfig;
use http::HeaderMap;
use http::Method;
@@ -73,6 +75,7 @@ async fn models_client_hits_models_endpoint() {
],
shell_type: ConfigShellToolType::ShellCommand,
visibility: ModelVisibility::List,
minimal_client_version: ClientVersion(0, 1, 0),
supported_in_api: true,
priority: 1,
upgrade: None,
@@ -84,8 +87,10 @@ async fn models_client_hits_models_endpoint() {
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
context_window: None,
reasoning_summary_format: ReasoningSummaryFormat::None,
experimental_supported_tools: Vec::new(),
}],
etag: String::new(),
};
Mock::given(method("GET"))
@@ -101,13 +106,13 @@ async fn models_client_hits_models_endpoint() {
let transport = ReqwestTransport::new(reqwest::Client::new());
let client = ModelsClient::new(transport, provider(&base_url), DummyAuth);
let (models, _) = client
let result = client
.list_models("0.1.0", HeaderMap::new())
.await
.expect("models request should succeed");
assert_eq!(models.len(), 1);
assert_eq!(models[0].slug, "gpt-test");
assert_eq!(result.models.len(), 1);
assert_eq!(result.models[0].slug, "gpt-test");
let received = server
.received_requests()

View File

@@ -69,15 +69,6 @@ impl ReqwestTransport {
#[async_trait]
impl HttpTransport for ReqwestTransport {
async fn execute(&self, req: Request) -> Result<Response, TransportError> {
if enabled!(Level::TRACE) {
trace!(
"{} to {}: {}",
req.method,
req.url,
req.body.as_ref().unwrap_or_default()
);
}
let builder = self.build(req)?;
let resp = builder.send().await.map_err(Self::map_error)?;
let status = resp.status();

View File

@@ -21,10 +21,3 @@ toml = { workspace = true, optional = true }
cli = ["clap", "serde", "toml"]
elapsed = []
sandbox_summary = []
[dev-dependencies]
clap = { workspace = true, features = ["derive", "wrap_help"] }
codex-utils-absolute-path = { workspace = true }
pretty_assertions = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }

View File

@@ -9,11 +9,8 @@ pub fn create_config_summary_entries(config: &Config, model: &str) -> Vec<(&'sta
("workdir", config.cwd.display().to_string()),
("model", model.to_string()),
("provider", config.model_provider_id.clone()),
("approval", config.approval_policy.value().to_string()),
(
"sandbox",
summarize_sandbox_policy(config.sandbox_policy.get()),
),
("approval", config.approval_policy.to_string()),
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
];
if config.model_provider.wire_api == WireApi::Responses {
let reasoning_effort = config

View File

@@ -26,22 +26,3 @@ impl From<SandboxModeCliArg> for SandboxMode {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn maps_cli_args_to_protocol_modes() {
assert_eq!(SandboxMode::ReadOnly, SandboxModeCliArg::ReadOnly.into());
assert_eq!(
SandboxMode::WorkspaceWrite,
SandboxModeCliArg::WorkspaceWrite.into()
);
assert_eq!(
SandboxMode::DangerFullAccess,
SandboxModeCliArg::DangerFullAccess.into()
);
}
}

View File

@@ -1,17 +1,9 @@
use codex_core::protocol::NetworkAccess;
use codex_core::protocol::SandboxPolicy;
pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String {
match sandbox_policy {
SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(),
SandboxPolicy::ReadOnly => "read-only".to_string(),
SandboxPolicy::ExternalSandbox { network_access } => {
let mut summary = "external-sandbox".to_string();
if matches!(network_access, NetworkAccess::Enabled) {
summary.push_str(" (network access enabled)");
}
summary
}
SandboxPolicy::WorkspaceWrite {
writable_roots,
network_access,
@@ -42,45 +34,3 @@ pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
#[test]
fn summarizes_external_sandbox_without_network_access_suffix() {
let summary = summarize_sandbox_policy(&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
});
assert_eq!(summary, "external-sandbox");
}
#[test]
fn summarizes_external_sandbox_with_enabled_network() {
let summary = summarize_sandbox_policy(&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
});
assert_eq!(summary, "external-sandbox (network access enabled)");
}
#[test]
fn workspace_write_summary_still_includes_network_access() {
let root = if cfg!(windows) { "C:\\repo" } else { "/repo" };
let writable_root = AbsolutePathBuf::try_from(root).unwrap();
let summary = summarize_sandbox_policy(&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![writable_root.clone()],
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
});
assert_eq!(
summary,
format!(
"workspace-write [workdir, {}] (network access enabled)",
writable_root.to_string_lossy()
)
);
}
}

View File

@@ -16,7 +16,6 @@ workspace = true
anyhow = { workspace = true }
async-channel = { workspace = true }
async-trait = { workspace = true }
arc-swap = "1.7.1"
base64 = { workspace = true }
chardetng = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
@@ -44,7 +43,6 @@ env-flags = { workspace = true }
eventsource-stream = { workspace = true }
futures = { workspace = true }
http = { workspace = true }
include_dir = { workspace = true }
indexmap = { workspace = true }
keyring = { workspace = true, features = ["crypto-rust"] }
libc = { workspace = true }
@@ -62,6 +60,7 @@ sha1 = { workspace = true }
sha2 = { workspace = true }
shlex = { workspace = true }
similar = { workspace = true }
strum_macros = { workspace = true }
tempfile = { workspace = true }
test-case = "3.3.1"
test-log = { workspace = true }
@@ -123,7 +122,6 @@ assert_cmd = { workspace = true }
assert_matches = { workspace = true }
codex-arg0 = { workspace = true }
codex-core = { path = ".", features = ["deterministic_process_ids"] }
codex-utils-cargo-bin = { workspace = true }
core_test_support = { workspace = true }
ctor = { workspace = true }
escargot = { workspace = true }
@@ -133,6 +131,7 @@ predicates = { workspace = true }
pretty_assertions = { workspace = true }
serial_test = { workspace = true }
tempfile = { workspace = true }
tokio-test = { workspace = true }
tracing-subscriber = { workspace = true }
tracing-test = { workspace = true, features = ["no-env-filter"] }
walkdir = { workspace = true }

View File

@@ -1,117 +0,0 @@
You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.
## General
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
- You may be in a dirty git worktree.
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
* If the changes are in unrelated files, just ignore them and don't revert them.
- Do not amend a commit unless explicitly requested to do so.
- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.
- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.
## Plan tool
When using the planning tool:
- Skip using the planning tool for straightforward tasks (roughly the easiest 25%).
- Do not make single-step plans.
- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.
## Codex CLI harness, sandboxing, and approvals
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:
- **read-only**: The sandbox only permits reading files.
- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are:
- **restricted**: Requires approval
- **enabled**: No approval needed
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
- (for all of these, you should weigh alternative paths that do not require approval)
When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
When requesting approval to execute a command that will require escalated privileges:
- Provide the `sandbox_permissions` parameter with the value `"require_escalated"`
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
## Special user requests
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
- If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.
## Frontend tasks
When doing frontend design tasks, avoid collapsing into "AI slop" or safe, average-looking layouts.
Aim for interfaces that feel intentional, bold, and a bit surprising.
- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).
- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.
- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.
- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.
- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
- Ensure the page loads properly on both desktop and mobile
Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language.
## Presenting your work and final message
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
- Default: be very concise; friendly coding teammate tone.
- Ask only when needed; suggest ideas; mirror the user's style.
- For substantial work, summarize clearly; follow finalanswer formatting.
- Skip heavy formatting for simple confirmations.
- Don't dump large files you've written; reference paths only.
- No "save/copy this file" - User is on the same machine.
- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.
- For code changes:
* Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in.
* If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.
* When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.
- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
### Final answer structure and style guidelines
- Plain text; CLI handles styling. Use structure only when it helps scanability.
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
- Bullets: use - ; merge related points; keep to one line when possible; 46 per list ordered by importance; keep phrasing consistent.
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
- Tone: collaborative, concise, factual; present tense, active voice; selfcontained; no "above/below"; parallel wording.
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.
- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.
- File References: When referencing files in your response follow the below rules:
* Use inline code to make file paths clickable.
* Each reference should have a stand alone path. Even if it's the same file.
* Accepted: absolute, workspacerelative, a/ or b/ diff prefixes, or bare filename/suffix.
* Optionally include line/column (1based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
* Do not use URIs like file://, vscode://, or https://.
* Do not provide range of lines
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5

File diff suppressed because one or more lines are too long

View File

@@ -636,7 +636,8 @@ mod tests {
use crate::auth::storage::FileAuthStorage;
use crate::auth::storage::get_auth_file;
use crate::config::Config;
use crate::config::ConfigBuilder;
use crate::config::ConfigOverrides;
use crate::config::ConfigToml;
use crate::token_data::IdTokenInfo;
use crate::token_data::KnownPlan as InternalKnownPlan;
use crate::token_data::PlanType as InternalPlanType;
@@ -861,16 +862,17 @@ mod tests {
Ok(fake_jwt)
}
async fn build_config(
fn build_config(
codex_home: &Path,
forced_login_method: Option<ForcedLoginMethod>,
forced_chatgpt_workspace_id: Option<String>,
) -> Config {
let mut config = ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.build()
.await
.expect("config should load");
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
codex_home.to_path_buf(),
)
.expect("config should load");
config.forced_login_method = forced_login_method;
config.forced_chatgpt_workspace_id = forced_chatgpt_workspace_id;
config
@@ -913,7 +915,7 @@ mod tests {
login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File)
.expect("seed api key");
let config = build_config(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None).await;
let config = build_config(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None);
let err = super::enforce_login_restrictions(&config)
.await
@@ -939,7 +941,7 @@ mod tests {
)
.expect("failed to write auth file");
let config = build_config(codex_home.path(), None, Some("org_mine".to_string())).await;
let config = build_config(codex_home.path(), None, Some("org_mine".to_string()));
let err = super::enforce_login_restrictions(&config)
.await
@@ -965,7 +967,7 @@ mod tests {
)
.expect("failed to write auth file");
let config = build_config(codex_home.path(), None, Some("org_mine".to_string())).await;
let config = build_config(codex_home.path(), None, Some("org_mine".to_string()));
super::enforce_login_restrictions(&config)
.await
@@ -983,7 +985,7 @@ mod tests {
login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File)
.expect("seed api key");
let config = build_config(codex_home.path(), None, Some("org_mine".to_string())).await;
let config = build_config(codex_home.path(), None, Some("org_mine".to_string()));
super::enforce_login_restrictions(&config)
.await
@@ -1000,7 +1002,7 @@ mod tests {
let _guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env");
let codex_home = tempdir().unwrap();
let config = build_config(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None).await;
let config = build_config(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None);
let err = super::enforce_login_restrictions(&config)
.await

Some files were not shown because too many files have changed in this diff Show More