Merge branch 'main' into etraut/tui-ide-ipc-codex-home

This commit is contained in:
Eric Traut
2026-07-06 20:48:57 -07:00
committed by GitHub
239 changed files with 6001 additions and 1340 deletions
+2 -1
View File
@@ -2,8 +2,9 @@
version = 1
name = "codex"
# TODO(anp) make it optional to specify this field
[setup]
script = "python ./.codex/environments/setup.py"
script = ""
[[actions]]
name = "Run"
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
"""Set up ignored files that should be shared with Codex worktrees."""
import shutil
import subprocess
from functools import cache
from pathlib import Path
@cache
def worktree_paths() -> tuple[Path, Path]:
script_dir = Path(__file__).resolve().parent
worktree_root = git_path(script_dir / "../..", "--show-toplevel")
common_git_dir = git_path(worktree_root, "--git-common-dir")
return worktree_root, common_git_dir.parent
def git_path(working_directory: Path, argument: str) -> Path:
output = subprocess.check_output(
[
"git",
"-C",
str(working_directory),
"rev-parse",
"--path-format=absolute",
argument,
],
text=True,
)
return Path(output.strip())
def copy_from_main_worktree_to_worktree(repo_relative_path: str) -> None:
relative_path = Path(repo_relative_path)
if relative_path.is_absolute() or ".." in relative_path.parts:
raise ValueError(f"path must be repository-relative: {repo_relative_path}")
worktree_root, main_worktree = worktree_paths()
source_path = main_worktree / relative_path
destination_path = worktree_root / relative_path
print(f" source: {source_path}")
print(f" destination: {destination_path}")
if source_path == destination_path:
print(" result: running in the main worktree; nothing to copy")
elif destination_path.exists():
print(" result: destination already exists; nothing to copy")
elif not source_path.is_file():
print(" result: source does not exist; nothing to copy")
else:
destination_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_path, destination_path)
print(f" result: copied {repo_relative_path}")
def main() -> None:
print("Codex environment setup:")
# See codex-rs/docs/bazel.md for the repository's Bazel workflow.
copy_from_main_worktree_to_worktree("user.bazelrc")
if __name__ == "__main__":
main()
@@ -7,10 +7,6 @@ inputs:
cache-scope:
description: Logical namespace used to keep concurrent Bazel jobs from reserving the same repository cache key.
required: true
install-test-prereqs:
description: Install DotSlash for Bazel-backed test jobs.
required: false
default: "false"
outputs:
repository-cache-path:
description: Filesystem path used for the Bazel repository cache.
@@ -30,7 +26,6 @@ runs:
uses: ./.github/actions/setup-bazel-ci
with:
target: ${{ inputs.target }}
install-test-prereqs: ${{ inputs.install-test-prereqs }}
- name: Compute bazel repository cache key
id: cache_bazel_repository_key
@@ -16,7 +16,6 @@ runs:
- uses: ./.github/actions/setup-bazel-ci
with:
target: ${{ inputs.target }}
install-test-prereqs: true
- name: Install Linux sandbox build dependencies
if: ${{ runner.os == 'Linux' }}
+2 -25
View File
@@ -1,13 +1,9 @@
name: setup-bazel-ci
description: Prepare a Bazel CI runner with shared caches and optional test prerequisites.
description: Prepare a Bazel CI runner with shared caches.
inputs:
target:
description: Target triple used for cache namespacing.
required: true
install-test-prereqs:
description: Install DotSlash for Bazel-backed test jobs.
required: false
default: "false"
outputs:
repository-cache-path:
description: Filesystem path used for the Bazel repository cache.
@@ -16,21 +12,7 @@ outputs:
runs:
using: composite
steps:
# Some integration tests rely on DotSlash being installed.
# See https://github.com/openai/codex/pull/7617.
- name: Install DotSlash
if: inputs.install-test-prereqs == 'true'
uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
- name: Make DotSlash available in PATH (Unix)
if: inputs.install-test-prereqs == 'true' && runner.os != 'Windows'
shell: bash
run: cp "$(which dotslash)" /usr/local/bin
- name: Make DotSlash available in PATH (Windows)
if: inputs.install-test-prereqs == 'true' && runner.os == 'Windows'
shell: pwsh
run: Copy-Item (Get-Command dotslash).Source -Destination "$env:LOCALAPPDATA\Microsoft\WindowsApps\dotslash.exe"
- uses: ./.github/actions/setup-ci
- name: Set up Bazel
uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # 0.19.0
@@ -124,8 +106,3 @@ runs:
if: runner.os == 'Windows'
shell: pwsh
run: ./.github/scripts/compute-bazel-windows-path.ps1
- name: Enable Git long paths (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: git config --global core.longpaths true
+39
View File
@@ -0,0 +1,39 @@
name: setup-ci
description: Prepare common tools and environment shared by CI jobs.
runs:
using: composite
steps:
- name: Prefer the Git CLI for Cargo git dependencies
shell: bash
run: echo "CARGO_NET_GIT_FETCH_WITH_CLI=true" >> "$GITHUB_ENV"
- name: Install DotSlash
uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
- name: Install just
uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49
with:
tool: just@1.51.0
# Some integration tests spawn DotSlash from a stable system path rather
# than inheriting the action-local PATH entry.
- name: Make DotSlash available in PATH (Unix)
if: runner.os != 'Windows'
shell: bash
run: |
if [[ -w /usr/local/bin ]]; then
cp "$(which dotslash)" /usr/local/bin
else
sudo cp "$(which dotslash)" /usr/local/bin
fi
- name: Make DotSlash available in PATH (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: Copy-Item (Get-Command dotslash).Source -Destination "$env:LOCALAPPDATA\Microsoft\WindowsApps\dotslash.exe"
- name: Enable Git long paths (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: git config --global core.longpaths true
@@ -9,6 +9,7 @@ Usage: build-codex-package-archive.sh \
--entrypoint-dir <dir> \
--archive-dir <dir> \
[--bwrap-bin <path>] \
[--code-mode-host-bin <path>] \
[--zsh-manifest <path>] \
[--codex-command-runner-bin <path>] \
[--codex-windows-sandbox-setup-bin <path>] \
@@ -23,6 +24,7 @@ archive_dir=""
target_suffixed_entrypoint="false"
resource_args=()
bwrap_bin_provided="false"
code_mode_host_bin_provided="false"
command_runner_bin_provided="false"
sandbox_setup_bin_provided="false"
@@ -49,6 +51,11 @@ while [[ $# -gt 0 ]]; do
bwrap_bin_provided="true"
shift 2
;;
--code-mode-host-bin)
resource_args+=(--code-mode-host-bin "${2:?--code-mode-host-bin requires a value}")
code_mode_host_bin_provided="true"
shift 2
;;
--zsh-manifest)
resource_args+=(--zsh-manifest "${2:?--zsh-manifest requires a value}")
shift 2
@@ -114,6 +121,11 @@ case "$target" in
;;
esac
code_mode_host_bin="${entrypoint_dir%/}/codex-code-mode-host${exe_suffix}"
if [[ "$code_mode_host_bin_provided" == "false" && -f "$code_mode_host_bin" ]]; then
resource_args+=(--code-mode-host-bin "$code_mode_host_bin")
fi
entrypoint_name="$entrypoint"
if [[ "$target_suffixed_entrypoint" == "true" ]]; then
entrypoint_name="${entrypoint_name}-${target}"
@@ -4,6 +4,7 @@ import unittest
from pathlib import Path
from v8_canary_changes import changed_files
from v8_canary_changes import canary_required
from v8_canary_changes import merge_base
from v8_canary_changes import resolved_v8_version
from v8_canary_changes import windows_source_required
@@ -46,6 +47,14 @@ version = "149.2.0"
)
)
def test_setup_ci_change_requires_canary_and_source_build(self) -> None:
changed_files = {".github/actions/setup-ci/action.yml"}
self.assertTrue(canary_required(changed_files, "149.2.0", "149.2.0"))
self.assertTrue(
windows_source_required(changed_files, "149.2.0", "149.2.0")
)
def test_manual_dispatch_requires_source_build(self) -> None:
self.assertTrue(
windows_source_required(
+13 -2
View File
@@ -21,6 +21,7 @@ ROOT = Path(__file__).resolve().parents[2]
CANARY_PATH_PATTERNS = {
".bazelrc",
".github/actions/setup-bazel-ci/**",
".github/actions/setup-ci/**",
".github/scripts/run_bazel_with_buildbuddy.py",
".github/scripts/rusty_v8_bazel.py",
".github/scripts/rusty_v8_module_bazel.py",
@@ -40,6 +41,7 @@ CANARY_PATH_PATTERNS = {
# Windows source builds are a narrower, more expensive subset of the canary.
# A V8 version change also requires them even when no path below changed.
WINDOWS_SOURCE_BUILD_PATHS = {
".github/actions/setup-ci/**",
".github/scripts/rusty_v8_bazel.py",
".github/scripts/rusty_v8_module_bazel.py",
".github/scripts/v8_canary_changes.py",
@@ -72,6 +74,15 @@ def canary_required(
)
def matching_windows_source_paths(changed_files: set[str]) -> set[str]:
"""Return changed paths that require Windows rusty_v8 source builds."""
return {
path
for path in changed_files
if any(fnmatchcase(path, pattern) for pattern in WINDOWS_SOURCE_BUILD_PATHS)
}
def resolved_v8_version(cargo_lock: bytes) -> str:
versions = sorted(
{
@@ -96,7 +107,7 @@ def windows_source_required(
return (
force
or base_v8_version != head_v8_version
or not changed_files.isdisjoint(WINDOWS_SOURCE_BUILD_PATHS)
or bool(matching_windows_source_paths(changed_files))
)
@@ -165,7 +176,7 @@ def main() -> None:
if matched_canary_paths
else "no relevant changes"
)
matched_windows_paths = sorted(files & WINDOWS_SOURCE_BUILD_PATHS)
matched_windows_paths = sorted(matching_windows_source_paths(files))
windows_source_reason = (
", ".join(matched_windows_paths)
if matched_windows_paths
+5 -12
View File
@@ -57,10 +57,12 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49
if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu'
- name: Prepare Bazel CI
id: prepare_bazel
uses: ./.github/actions/prepare-bazel-ci
with:
tool: just
target: ${{ matrix.target }}
cache-scope: bazel-${{ github.job }}
- name: Check rusty_v8 MODULE.bazel checksums
if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu'
@@ -69,13 +71,6 @@ jobs:
python3 .github/scripts/rusty_v8_bazel.py check-module-bazel
just test-github-scripts
- name: Prepare Bazel CI
id: prepare_bazel
uses: ./.github/actions/prepare-bazel-ci
with:
target: ${{ matrix.target }}
cache-scope: bazel-${{ github.job }}
install-test-prereqs: "true"
- name: Check MODULE.bazel.lock is up to date
if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu'
shell: bash
@@ -176,7 +171,6 @@ jobs:
# not save it from every shard below; duplicate uploads would sit on
# the PR-blocking critical path after the useful test work is done.
cache-scope: bazel-test
install-test-prereqs: "true"
- name: bazel test shard
env:
@@ -287,7 +281,6 @@ jobs:
with:
target: x86_64-pc-windows-gnullvm
cache-scope: bazel-${{ github.job }}
install-test-prereqs: "true"
- name: bazel test //...
env:
+2 -5
View File
@@ -3,11 +3,6 @@ name: cargo-deny
on:
workflow_call:
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
# nested submodules. Prefer the system git CLI across every Cargo invocation.
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
jobs:
cargo-deny:
runs-on: ubuntu-latest
@@ -21,6 +16,8 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- uses: ./.github/actions/setup-ci
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
+2 -5
View File
@@ -16,6 +16,8 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- uses: ./.github/actions/setup-ci
- name: Verify codex-rs Cargo manifests inherit workspace settings
run: python3 .github/scripts/verify_cargo_workspace_manifests.py
@@ -76,15 +78,10 @@ jobs:
- name: Check root README ToC
run: python3 scripts/readme_toc.py README.md
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49
with:
tool: just@1.51.0
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: "0.11.3"
- name: Install DotSlash
uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
- name: Check formatting (run `just fmt` to fix)
run: just fmt-check
@@ -48,11 +48,6 @@ on:
default: false
type: boolean
# Caller workflow-level env does not flow through workflow_call, so keep the
# Cargo git transport hardening on the archive and shard jobs directly here.
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
jobs:
archive:
name: Build nextest archive
@@ -81,6 +76,8 @@ jobs:
shell: pwsh
run: ../.github/scripts/setup-dev-drive.ps1
- uses: ./.github/actions/setup-ci
- name: Install Linux build dependencies
if: ${{ runner.os == 'Linux' }}
shell: bash
@@ -91,9 +88,6 @@ jobs:
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev bubblewrap
fi
- name: Install DotSlash
uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
with:
targets: ${{ inputs.target }}
@@ -306,6 +300,8 @@ jobs:
with:
persist-credentials: false
- uses: ./.github/actions/setup-ci
- name: Install Linux build dependencies
if: ${{ runner.os == 'Linux' }}
shell: bash
@@ -316,9 +312,6 @@ jobs:
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev bubblewrap
fi
- name: Install DotSlash
uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
with:
targets: ${{ inputs.target }}
+4 -11
View File
@@ -8,13 +8,6 @@ on:
- "**full-ci**"
workflow_dispatch:
# CI builds in debug (dev) for faster signal.
env:
# Cargo's libgit2 transport has been flaky on macOS when fetching git
# dependencies with nested submodules. Use the system git CLI, which has
# better network/proxy behavior and matches Cargo's own suggested fallback.
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
jobs:
# --- CI that doesn't need specific targets ---------------------------------
general:
@@ -27,12 +20,10 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: ./.github/actions/setup-ci
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
with:
components: rustfmt
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49
with:
tool: just
- name: cargo fmt
run: cargo fmt -- --config imports_granularity=Item --check
- name: Rust benchmark smoke test
@@ -48,6 +39,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: ./.github/actions/setup-ci
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49
with:
@@ -65,6 +57,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: ./.github/actions/setup-ci
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
with:
toolchain: nightly-2025-09-18
@@ -123,7 +116,6 @@ jobs:
- uses: ./.github/actions/setup-bazel-ci
with:
target: ${{ runner.os }}
install-test-prereqs: true
- name: Install Linux sandbox build dependencies
if: ${{ runner.os == 'Linux' }}
shell: bash
@@ -256,6 +248,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: ./.github/actions/setup-ci
- name: Install Linux build dependencies
if: ${{ runner.os == 'Linux' }}
shell: bash
+3 -8
View File
@@ -3,11 +3,6 @@ on:
workflow_call:
workflow_dispatch:
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
# nested submodules. Prefer the system git CLI across every Cargo invocation.
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
jobs:
# --- Detect what changed so the fast PR workflow only runs relevant jobs ----
changed:
@@ -76,12 +71,10 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- uses: ./.github/actions/setup-ci
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
with:
components: rustfmt
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49
with:
tool: just
- name: cargo fmt
run: cargo fmt -- --config imports_granularity=Item --check
- name: Rust benchmark smoke test
@@ -104,6 +97,7 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- uses: ./.github/actions/setup-ci
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49
with:
@@ -128,6 +122,7 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- uses: ./.github/actions/setup-ci
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
- name: Install nightly argument-comment-lint toolchain
shell: bash
+9 -11
View File
@@ -3,11 +3,8 @@ name: rust-release-windows
on:
workflow_call:
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
# nested submodules. Prefer the system git CLI across every Cargo invocation.
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
WINDOWS_BINARIES: "codex codex-responses-api-proxy codex-windows-sandbox-setup codex-command-runner codex-app-server"
WINDOWS_BINARIES: "codex codex-code-mode-host codex-responses-api-proxy codex-windows-sandbox-setup codex-command-runner codex-app-server"
jobs:
build-windows-binaries:
@@ -28,14 +25,14 @@ jobs:
- runner: windows-x64
target: x86_64-pc-windows-msvc
bundle: primary
binaries: "codex codex-responses-api-proxy"
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
runs_on:
group: ${{ github.event.repository.name }}-runners
labels: ${{ github.event.repository.name }}-windows-x64
- runner: windows-arm64
target: aarch64-pc-windows-msvc
bundle: primary
binaries: "codex codex-responses-api-proxy"
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
runs_on:
group: ${{ github.event.repository.name }}-runners
labels: ${{ github.event.repository.name }}-windows-arm64
@@ -56,14 +53,14 @@ jobs:
- runner: windows-x64
target: x86_64-pc-windows-msvc
bundle: app-server
binaries: "codex-app-server"
binaries: "codex-app-server codex-code-mode-host"
runs_on:
group: ${{ github.event.repository.name }}-runners
labels: ${{ github.event.repository.name }}-windows-x64
- runner: windows-arm64
target: aarch64-pc-windows-msvc
bundle: app-server
binaries: "codex-app-server"
binaries: "codex-app-server codex-code-mode-host"
runs_on:
group: ${{ github.event.repository.name }}-runners
labels: ${{ github.event.repository.name }}-windows-arm64
@@ -72,6 +69,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: ./.github/actions/setup-ci
- name: Print runner specs (Windows)
shell: powershell
run: |
@@ -164,6 +162,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: ./.github/actions/setup-ci
- name: Download prebuilt Windows binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
@@ -221,6 +220,8 @@ jobs:
with:
persist-credentials: false
- uses: ./.github/actions/setup-ci
- name: Download prebuilt Windows primary binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
@@ -270,9 +271,6 @@ jobs:
"$dest/${binary}-${{ matrix.target }}.exe"
done
- name: Install DotSlash
uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
- name: Build Codex package archives
shell: bash
run: |
+43 -22
View File
@@ -83,50 +83,50 @@ jobs:
target: aarch64-apple-darwin
bundle: primary
artifact_name: aarch64-apple-darwin
binaries: "codex codex-responses-api-proxy"
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
build_dmg: "true"
- runner: macos-15-xlarge
target: aarch64-apple-darwin
bundle: app-server
artifact_name: aarch64-apple-darwin-app-server
binaries: "codex-app-server"
binaries: "codex-app-server codex-code-mode-host"
build_dmg: "false"
- runner: macos-15-xlarge
target: x86_64-apple-darwin
bundle: primary
artifact_name: x86_64-apple-darwin
binaries: "codex codex-responses-api-proxy"
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
build_dmg: "true"
- runner: macos-15-xlarge
target: x86_64-apple-darwin
bundle: app-server
artifact_name: x86_64-apple-darwin-app-server
binaries: "codex-app-server"
binaries: "codex-app-server codex-code-mode-host"
build_dmg: "false"
# Release artifacts intentionally ship MUSL-linked Linux binaries.
- runner: ${{ github.event.repository.name }}-linux-x64-xl
target: x86_64-unknown-linux-musl
bundle: primary
artifact_name: x86_64-unknown-linux-musl
binaries: "codex codex-responses-api-proxy bwrap"
binaries: "codex codex-code-mode-host codex-responses-api-proxy bwrap"
build_dmg: "false"
- runner: ${{ github.event.repository.name }}-linux-x64-xl
target: x86_64-unknown-linux-musl
bundle: app-server
artifact_name: x86_64-unknown-linux-musl-app-server
binaries: "codex-app-server"
binaries: "codex-app-server codex-code-mode-host"
build_dmg: "false"
- runner: ${{ github.event.repository.name }}-linux-arm64
target: aarch64-unknown-linux-musl
bundle: primary
artifact_name: aarch64-unknown-linux-musl
binaries: "codex codex-responses-api-proxy bwrap"
binaries: "codex codex-code-mode-host codex-responses-api-proxy bwrap"
build_dmg: "false"
- runner: ${{ github.event.repository.name }}-linux-arm64
target: aarch64-unknown-linux-musl
bundle: app-server
artifact_name: aarch64-unknown-linux-musl-app-server
binaries: "codex-app-server"
binaries: "codex-app-server codex-code-mode-host"
build_dmg: "false"
steps:
@@ -335,6 +335,11 @@ jobs:
mkdir -p "$dest"
for binary in ${{ matrix.binaries }}; do
# Both variants package the host, but only the primary bundle publishes
# standalone binary archives to avoid duplicate release asset names.
if [[ "${{ matrix.bundle }}" == "app-server" && "$binary" == "codex-code-mode-host" ]]; then
continue
fi
cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}"
if [[ "${{ matrix.target }}" == *linux* ]]; then
cp "target/${{ matrix.target }}/release/${binary}.sigstore" \
@@ -347,9 +352,13 @@ jobs:
rm -rf "$bundle_root"
mkdir -p "$bundle_root/codex-resources"
cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex"
cp "$dest/codex-code-mode-host-${{ matrix.target }}" "$bundle_root/codex-code-mode-host"
cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap"
chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap"
tar -C "$bundle_root" -cf - codex codex-resources/bwrap |
chmod 0755 \
"$bundle_root/codex" \
"$bundle_root/codex-code-mode-host" \
"$bundle_root/codex-resources/bwrap"
tar -C "$bundle_root" -cf - codex codex-code-mode-host codex-resources/bwrap |
zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst"
fi
@@ -497,19 +506,19 @@ jobs:
- target: aarch64-apple-darwin
bundle: primary
artifact_name: aarch64-apple-darwin
binaries: "codex codex-responses-api-proxy"
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
- target: aarch64-apple-darwin
bundle: app-server
artifact_name: aarch64-apple-darwin-app-server
binaries: "codex-app-server"
binaries: "codex-app-server codex-code-mode-host"
- target: x86_64-apple-darwin
bundle: primary
artifact_name: x86_64-apple-darwin
binaries: "codex codex-responses-api-proxy"
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
- target: x86_64-apple-darwin
bundle: app-server
artifact_name: x86_64-apple-darwin-app-server
binaries: "codex-app-server"
binaries: "codex-app-server codex-code-mode-host"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -615,22 +624,22 @@ jobs:
- target: aarch64-apple-darwin
bundle: primary
artifact_name: aarch64-apple-darwin
binaries: "codex codex-responses-api-proxy"
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
build_dmg: "true"
- target: aarch64-apple-darwin
bundle: app-server
artifact_name: aarch64-apple-darwin-app-server
binaries: "codex-app-server"
binaries: "codex-app-server codex-code-mode-host"
build_dmg: "false"
- target: x86_64-apple-darwin
bundle: primary
artifact_name: x86_64-apple-darwin
binaries: "codex codex-responses-api-proxy"
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
build_dmg: "true"
- target: x86_64-apple-darwin
bundle: app-server
artifact_name: x86_64-apple-darwin-app-server
binaries: "codex-app-server"
binaries: "codex-app-server codex-code-mode-host"
build_dmg: "false"
steps:
@@ -707,6 +716,11 @@ jobs:
mkdir -p "$dest"
for binary in ${{ matrix.binaries }}; do
# Both variants package the host, but only the primary bundle publishes
# standalone binary archives to avoid duplicate release asset names.
if [[ "${{ matrix.bundle }}" == "app-server" && "$binary" == "codex-code-mode-host" ]]; then
continue
fi
cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}"
done
@@ -911,22 +925,22 @@ jobs:
- target: aarch64-apple-darwin
bundle: primary
artifact_name: aarch64-apple-darwin
binaries: "codex codex-responses-api-proxy"
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
verify_dmg: "true"
- target: aarch64-apple-darwin
bundle: app-server
artifact_name: aarch64-apple-darwin-app-server
binaries: "codex-app-server"
binaries: "codex-app-server codex-code-mode-host"
verify_dmg: "false"
- target: x86_64-apple-darwin
bundle: primary
artifact_name: x86_64-apple-darwin
binaries: "codex codex-responses-api-proxy"
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
verify_dmg: "true"
- target: x86_64-apple-darwin
bundle: app-server
artifact_name: x86_64-apple-darwin-app-server
binaries: "codex-app-server"
binaries: "codex-app-server codex-code-mode-host"
verify_dmg: "false"
steps:
@@ -983,6 +997,12 @@ jobs:
binary_path="${RUNNER_TEMP}/signed-binaries/${binary}"
verify_signed_binary "$binary_path"
# The app-server package contains the host, but its standalone archives
# are omitted above to avoid duplicate release asset names.
if [[ "${{ matrix.bundle }}" == "app-server" && "$binary" == "codex-code-mode-host" ]]; then
continue
fi
direct_archive_dir="${RUNNER_TEMP}/direct-archive-${binary}-${target}"
rm -rf "$direct_archive_dir"
mkdir -p "$direct_archive_dir"
@@ -1014,6 +1034,7 @@ jobs:
mkdir -p "$package_dir"
tar -xzf "${packaged_dir}/${package_stem}-${target}.tar.gz" -C "$package_dir"
verify_signed_binary "${package_dir}/bin/${package_entrypoint}"
verify_signed_binary "${package_dir}/bin/codex-code-mode-host"
if [[ "${{ matrix.verify_dmg }}" != "true" ]]; then
exit 0
+2 -5
View File
@@ -9,11 +9,6 @@ on:
pull_request: {}
workflow_dispatch:
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
# nested submodules. Prefer the system git CLI for Cargo builds and smoke tests.
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
concurrency:
group: ${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}
cancel-in-progress: ${{ github.ref_name != 'main' }}
@@ -325,6 +320,8 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: ./.github/actions/setup-ci
- name: Configure git for upstream checkout
shell: bash
run: git config --global core.symlinks true
+1
View File
@@ -0,0 +1 @@
user.bazelrc
+1 -1
View File
@@ -804,7 +804,7 @@
"critical-section_1.2.0": "{\"dependencies\":[],\"features\":{\"restore-state-bool\":[],\"restore-state-none\":[],\"restore-state-u16\":[],\"restore-state-u32\":[],\"restore-state-u64\":[],\"restore-state-u8\":[],\"restore-state-usize\":[],\"std\":[\"restore-state-bool\"]}}",
"crossbeam-channel_0.5.15": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.13.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"^0.3\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"crossbeam-utils/std\"]}}",
"crossbeam-deque_0.8.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-epoch\",\"req\":\"^0.9.17\"},{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"crossbeam-epoch/std\",\"crossbeam-utils/std\"]}}",
"crossbeam-epoch_0.9.18": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"name\":\"loom-crate\",\"optional\":true,\"package\":\"loom\",\"req\":\"^0.7.1\",\"target\":\"cfg(crossbeam_loom)\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"loom\":[\"loom-crate\",\"crossbeam-utils/loom\"],\"nightly\":[\"crossbeam-utils/nightly\"],\"std\":[\"alloc\",\"crossbeam-utils/std\"]}}",
"crossbeam-epoch_0.9.20": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"name\":\"loom-crate\",\"optional\":true,\"package\":\"loom\",\"req\":\"^0.7.1\",\"target\":\"cfg(crossbeam_loom)\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"loom\":[\"loom-crate\",\"crossbeam-utils/loom\"],\"nightly\":[\"crossbeam-utils/nightly\"],\"std\":[\"alloc\",\"crossbeam-utils/std\"]}}",
"crossbeam-queue_0.3.12": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"nightly\":[\"crossbeam-utils/nightly\"],\"std\":[\"alloc\",\"crossbeam-utils/std\"]}}",
"crossbeam-utils_0.8.21": "{\"dependencies\":[{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7.1\",\"target\":\"cfg(crossbeam_loom)\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"std\"],\"nightly\":[],\"std\":[]}}",
"crossterm_winapi_0.9.1": "{\"dependencies\":[{\"features\":[\"winbase\",\"consoleapi\",\"processenv\",\"handleapi\",\"synchapi\",\"impl-default\"],\"name\":\"winapi\",\"req\":\"^0.3.8\",\"target\":\"cfg(windows)\"}],\"features\":{}}",
+42 -29
View File
@@ -1936,6 +1936,7 @@ dependencies = [
"bytes",
"chrono",
"codex-client",
"codex-http-client",
"codex-protocol",
"codex-utils-rustls-provider",
"eventsource-stream",
@@ -2262,7 +2263,7 @@ dependencies = [
"anyhow",
"codex-api",
"codex-backend-openapi-models",
"codex-client",
"codex-http-client",
"codex-login",
"codex-model-provider",
"codex-protocol",
@@ -2392,33 +2393,12 @@ dependencies = [
name = "codex-client"
version = "0.0.0"
dependencies = [
"bytes",
"codex-utils-cargo-bin",
"codex-utils-rustls-provider",
"codex-http-client",
"eventsource-stream",
"futures",
"http 1.4.0",
"opentelemetry",
"opentelemetry_sdk",
"pretty_assertions",
"rand 0.9.3",
"rcgen",
"reqwest 0.12.28",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"serde",
"serde_json",
"sha2 0.10.9",
"system-configuration",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
"windows-sys 0.52.0",
"zstd 0.13.3",
]
[[package]]
@@ -2452,11 +2432,11 @@ dependencies = [
"anyhow",
"chrono",
"clap",
"codex-client",
"codex-cloud-tasks-client",
"codex-cloud-tasks-mock-client",
"codex-core",
"codex-git-utils",
"codex-http-client",
"codex-login",
"codex-model-provider",
"codex-tui",
@@ -2526,6 +2506,7 @@ dependencies = [
"codex-utils-cargo-bin",
"pretty_assertions",
"serde_json",
"tempfile",
"tokio",
"tokio-util",
]
@@ -2915,9 +2896,9 @@ dependencies = [
"bytes",
"clatter",
"codex-api",
"codex-client",
"codex-exec-server-protocol",
"codex-file-system",
"codex-http-client",
"codex-network-proxy",
"codex-otel",
"codex-protocol",
@@ -3213,6 +3194,37 @@ dependencies = [
"uuid",
]
[[package]]
name = "codex-http-client"
version = "0.0.0"
dependencies = [
"bytes",
"codex-utils-cargo-bin",
"codex-utils-rustls-provider",
"futures",
"http 1.4.0",
"opentelemetry",
"opentelemetry_sdk",
"pretty_assertions",
"rcgen",
"reqwest 0.12.28",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"serde",
"serde_json",
"sha2 0.10.9",
"system-configuration",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
"windows-sys 0.52.0",
"zstd 0.13.3",
]
[[package]]
name = "codex-image-generation-extension"
version = "0.0.0"
@@ -3264,6 +3276,7 @@ dependencies = [
"clap",
"codex-core",
"codex-install-context",
"codex-network-proxy",
"codex-process-hardening",
"codex-protocol",
"codex-sandboxing",
@@ -3303,8 +3316,8 @@ dependencies = [
"base64 0.22.1",
"chrono",
"codex-agent-identity",
"codex-client",
"codex-config",
"codex-http-client",
"codex-keyring-store",
"codex-model-provider-info",
"codex-otel",
@@ -3518,8 +3531,8 @@ dependencies = [
"codex-agent-identity",
"codex-api",
"codex-aws-auth",
"codex-client",
"codex-feedback",
"codex-http-client",
"codex-login",
"codex-model-provider-info",
"codex-models-manager",
@@ -4821,9 +4834,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
+2
View File
@@ -41,6 +41,7 @@ members = [
"core-plugins",
"core-skills",
"hooks",
"http-client",
"secrets",
"exec",
"file-system",
@@ -164,6 +165,7 @@ codex-cloud-tasks-mock-client = { path = "cloud-tasks-mock-client" }
codex-code-mode = { path = "code-mode" }
codex-code-mode-protocol = { path = "code-mode-protocol" }
codex-home = { path = "codex-home" }
codex-http-client = { path = "http-client" }
codex-config = { path = "config" }
codex-connectors = { path = "connectors" }
codex-connectors-extension = { path = "ext/connectors" }
-3
View File
@@ -78,9 +78,6 @@ pub use crate::remote::RemoteAppServerEndpoint;
/// module exists so clients can remove a direct `codex-core` dependency
/// while legacy startup/config paths are migrated to RPCs.
pub mod legacy_core {
pub use codex_core::check_execpolicy_for_warnings;
pub use codex_core::format_exec_policy_error_with_source;
pub mod config {
pub use codex_core::config::*;
@@ -556,6 +556,13 @@
},
"ConsumeAccountRateLimitResetCreditParams": {
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -8688,6 +8688,13 @@
"ConsumeAccountRateLimitResetCreditParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -13416,6 +13423,13 @@
],
"type": "string"
},
"PluginInstallPolicySource": {
"enum": [
"WORKSPACE_SETTING",
"IMPLICIT_CANONICAL_APP"
],
"type": "string"
},
"PluginInstallResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -14299,6 +14313,16 @@
"installPolicy": {
"$ref": "#/definitions/v2/PluginInstallPolicy"
},
"installPolicySource": {
"anyOf": [
{
"$ref": "#/definitions/v2/PluginInstallPolicySource"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},
@@ -14533,11 +14557,78 @@
],
"type": "string"
},
"RateLimitResetCredit": {
"properties": {
"description": {
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
},
"expiresAt": {
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"grantedAt": {
"description": "Unix timestamp in seconds when the credit was granted.",
"format": "int64",
"type": "integer"
},
"id": {
"description": "Opaque backend identifier for this reset credit.",
"type": "string"
},
"resetType": {
"$ref": "#/definitions/v2/RateLimitResetType"
},
"status": {
"$ref": "#/definitions/v2/RateLimitResetCreditStatus"
},
"title": {
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
}
},
"required": [
"grantedAt",
"id",
"resetType",
"status"
],
"type": "object"
},
"RateLimitResetCreditStatus": {
"enum": [
"available",
"redeeming",
"redeemed",
"unknown"
],
"type": "string"
},
"RateLimitResetCreditsSummary": {
"properties": {
"availableCount": {
"format": "int64",
"type": "integer"
},
"credits": {
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
"items": {
"$ref": "#/definitions/v2/RateLimitResetCredit"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -14545,6 +14636,13 @@
],
"type": "object"
},
"RateLimitResetType": {
"enum": [
"codexRateLimits",
"unknown"
],
"type": "string"
},
"RateLimitSnapshot": {
"properties": {
"credits": {
@@ -4928,6 +4928,13 @@
"ConsumeAccountRateLimitResetCreditParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -9820,6 +9827,13 @@
],
"type": "string"
},
"PluginInstallPolicySource": {
"enum": [
"WORKSPACE_SETTING",
"IMPLICIT_CANONICAL_APP"
],
"type": "string"
},
"PluginInstallResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -10703,6 +10717,16 @@
"installPolicy": {
"$ref": "#/definitions/PluginInstallPolicy"
},
"installPolicySource": {
"anyOf": [
{
"$ref": "#/definitions/PluginInstallPolicySource"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},
@@ -10937,11 +10961,78 @@
],
"type": "string"
},
"RateLimitResetCredit": {
"properties": {
"description": {
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
},
"expiresAt": {
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"grantedAt": {
"description": "Unix timestamp in seconds when the credit was granted.",
"format": "int64",
"type": "integer"
},
"id": {
"description": "Opaque backend identifier for this reset credit.",
"type": "string"
},
"resetType": {
"$ref": "#/definitions/RateLimitResetType"
},
"status": {
"$ref": "#/definitions/RateLimitResetCreditStatus"
},
"title": {
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
}
},
"required": [
"grantedAt",
"id",
"resetType",
"status"
],
"type": "object"
},
"RateLimitResetCreditStatus": {
"enum": [
"available",
"redeeming",
"redeemed",
"unknown"
],
"type": "string"
},
"RateLimitResetCreditsSummary": {
"properties": {
"availableCount": {
"format": "int64",
"type": "integer"
},
"credits": {
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
"items": {
"$ref": "#/definitions/RateLimitResetCredit"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -10949,6 +11040,13 @@
],
"type": "object"
},
"RateLimitResetType": {
"enum": [
"codexRateLimits",
"unknown"
],
"type": "string"
},
"RateLimitSnapshot": {
"properties": {
"credits": {
@@ -1,6 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -49,11 +49,78 @@
],
"type": "string"
},
"RateLimitResetCredit": {
"properties": {
"description": {
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
},
"expiresAt": {
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"grantedAt": {
"description": "Unix timestamp in seconds when the credit was granted.",
"format": "int64",
"type": "integer"
},
"id": {
"description": "Opaque backend identifier for this reset credit.",
"type": "string"
},
"resetType": {
"$ref": "#/definitions/RateLimitResetType"
},
"status": {
"$ref": "#/definitions/RateLimitResetCreditStatus"
},
"title": {
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
}
},
"required": [
"grantedAt",
"id",
"resetType",
"status"
],
"type": "object"
},
"RateLimitResetCreditStatus": {
"enum": [
"available",
"redeeming",
"redeemed",
"unknown"
],
"type": "string"
},
"RateLimitResetCreditsSummary": {
"properties": {
"availableCount": {
"format": "int64",
"type": "integer"
},
"credits": {
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
"items": {
"$ref": "#/definitions/RateLimitResetCredit"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -61,6 +128,13 @@
],
"type": "object"
},
"RateLimitResetType": {
"enum": [
"codexRateLimits",
"unknown"
],
"type": "string"
},
"RateLimitSnapshot": {
"properties": {
"credits": {
@@ -63,6 +63,13 @@
],
"type": "string"
},
"PluginInstallPolicySource": {
"enum": [
"WORKSPACE_SETTING",
"IMPLICIT_CANONICAL_APP"
],
"type": "string"
},
"PluginInterface": {
"properties": {
"brandColor": {
@@ -489,6 +496,16 @@
"installPolicy": {
"$ref": "#/definitions/PluginInstallPolicy"
},
"installPolicySource": {
"anyOf": [
{
"$ref": "#/definitions/PluginInstallPolicySource"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},
@@ -63,6 +63,13 @@
],
"type": "string"
},
"PluginInstallPolicySource": {
"enum": [
"WORKSPACE_SETTING",
"IMPLICIT_CANONICAL_APP"
],
"type": "string"
},
"PluginInterface": {
"properties": {
"brandColor": {
@@ -489,6 +496,16 @@
"installPolicy": {
"$ref": "#/definitions/PluginInstallPolicy"
},
"installPolicySource": {
"anyOf": [
{
"$ref": "#/definitions/PluginInstallPolicySource"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},
@@ -242,6 +242,13 @@
],
"type": "string"
},
"PluginInstallPolicySource": {
"enum": [
"WORKSPACE_SETTING",
"IMPLICIT_CANONICAL_APP"
],
"type": "string"
},
"PluginInterface": {
"properties": {
"brandColor": {
@@ -629,6 +636,16 @@
"installPolicy": {
"$ref": "#/definitions/PluginInstallPolicy"
},
"installPolicySource": {
"anyOf": [
{
"$ref": "#/definitions/PluginInstallPolicySource"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},
@@ -37,6 +37,13 @@
],
"type": "string"
},
"PluginInstallPolicySource": {
"enum": [
"WORKSPACE_SETTING",
"IMPLICIT_CANONICAL_APP"
],
"type": "string"
},
"PluginInterface": {
"properties": {
"brandColor": {
@@ -445,6 +452,16 @@
"installPolicy": {
"$ref": "#/definitions/PluginInstallPolicy"
},
"installPolicySource": {
"anyOf": [
{
"$ref": "#/definitions/PluginInstallPolicySource"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},
@@ -7,4 +7,9 @@ export type ConsumeAccountRateLimitResetCreditParams = {
* Identifies one logical reset attempt. A UUID is recommended; reuse the same value when
* retrying that attempt.
*/
idempotencyKey: string, };
idempotencyKey: string,
/**
* Opaque reset-credit identifier to redeem. When omitted, the backend selects the next
* available credit.
*/
creditId?: string | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginInstallPolicySource = "WORKSPACE_SETTING" | "IMPLICIT_CANONICAL_APP";
@@ -4,6 +4,7 @@
import type { PluginAuthPolicy } from "./PluginAuthPolicy";
import type { PluginAvailability } from "./PluginAvailability";
import type { PluginInstallPolicy } from "./PluginInstallPolicy";
import type { PluginInstallPolicySource } from "./PluginInstallPolicySource";
import type { PluginInterface } from "./PluginInterface";
import type { PluginShareContext } from "./PluginShareContext";
import type { PluginSource } from "./PluginSource";
@@ -24,7 +25,7 @@ localVersion: string | null, name: string,
/**
* Remote sharing context associated with this plugin when available.
*/
shareContext: PluginShareContext | null, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, authPolicy: PluginAuthPolicy,
shareContext: PluginShareContext | null, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, installPolicySource: PluginInstallPolicySource | null, authPolicy: PluginAuthPolicy,
/**
* Availability state for installing and using the plugin.
*/
@@ -0,0 +1,27 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RateLimitResetCreditStatus } from "./RateLimitResetCreditStatus";
import type { RateLimitResetType } from "./RateLimitResetType";
export type RateLimitResetCredit = {
/**
* Opaque backend identifier for this reset credit.
*/
id: string, resetType: RateLimitResetType, status: RateLimitResetCreditStatus,
/**
* Unix timestamp in seconds when the credit was granted.
*/
grantedAt: number,
/**
* Unix timestamp in seconds when the credit expires, or `null` if it does not expire.
*/
expiresAt: number | null,
/**
* Backend-provided display title for this credit, or `null` when unavailable.
*/
title: string | null,
/**
* Backend-provided display description for this credit, or `null` when unavailable.
*/
description: string | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type RateLimitResetCreditStatus = "available" | "redeeming" | "redeemed" | "unknown";
@@ -1,5 +1,14 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RateLimitResetCredit } from "./RateLimitResetCredit";
export type RateLimitResetCreditsSummary = { availableCount: bigint, };
export type RateLimitResetCreditsSummary = { availableCount: bigint,
/**
* Detail rows for available reset credits, when the backend provides them.
*
* `null` means only `availableCount` is known, while an empty array means details were fetched
* and no available credits were returned. The backend may cap this list, so its length can be
* less than `availableCount`.
*/
credits: Array<RateLimitResetCredit> | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type RateLimitResetType = "codexRateLimits" | "unknown";
@@ -294,6 +294,7 @@ export type { PluginDetail } from "./PluginDetail";
export type { PluginHookSummary } from "./PluginHookSummary";
export type { PluginInstallParams } from "./PluginInstallParams";
export type { PluginInstallPolicy } from "./PluginInstallPolicy";
export type { PluginInstallPolicySource } from "./PluginInstallPolicySource";
export type { PluginInstallResponse } from "./PluginInstallResponse";
export type { PluginInstalledParams } from "./PluginInstalledParams";
export type { PluginInstalledResponse } from "./PluginInstalledResponse";
@@ -335,7 +336,10 @@ export type { ProcessOutputDeltaNotification } from "./ProcessOutputDeltaNotific
export type { ProcessOutputStream } from "./ProcessOutputStream";
export type { ProcessTerminalSize } from "./ProcessTerminalSize";
export type { RateLimitReachedType } from "./RateLimitReachedType";
export type { RateLimitResetCredit } from "./RateLimitResetCredit";
export type { RateLimitResetCreditStatus } from "./RateLimitResetCreditStatus";
export type { RateLimitResetCreditsSummary } from "./RateLimitResetCreditsSummary";
export type { RateLimitResetType } from "./RateLimitResetType";
export type { RateLimitSnapshot } from "./RateLimitSnapshot";
export type { RateLimitWindow } from "./RateLimitWindow";
export type { RawResponseItemCompletedNotification } from "./RawResponseItemCompletedNotification";
@@ -3195,6 +3195,7 @@ mod tests {
request_id: RequestId::Integer(9),
params: v2::ThreadRealtimeStartParams {
client_managed_handoffs: Some(true),
flush_transcript_tail_on_session_end: Some(true),
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: Some("silent context".to_string()),
@@ -3216,6 +3217,7 @@ mod tests {
"params": {
"threadId": "thr_123",
"clientManagedHandoffs": true,
"flushTranscriptTailOnSessionEnd": true,
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffPrefix": "silent context",
@@ -3240,6 +3242,7 @@ mod tests {
request_id: RequestId::Integer(9),
params: v2::ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -3261,6 +3264,7 @@ mod tests {
"params": {
"threadId": "thr_123",
"clientManagedHandoffs": null,
"flushTranscriptTailOnSessionEnd": null,
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffPrefix": null,
@@ -3280,6 +3284,7 @@ mod tests {
request_id: RequestId::Integer(9),
params: v2::ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -3301,6 +3306,7 @@ mod tests {
"params": {
"threadId": "thr_123",
"clientManagedHandoffs": null,
"flushTranscriptTailOnSessionEnd": null,
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffPrefix": null,
@@ -3518,6 +3524,7 @@ mod tests {
request_id: RequestId::Integer(1),
params: v2::ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -284,6 +284,52 @@ pub struct GetAccountRateLimitsResponse {
#[ts(export_to = "v2/")]
pub struct RateLimitResetCreditsSummary {
pub available_count: i64,
/// Detail rows for available reset credits, when the backend provides them.
///
/// `null` means only `availableCount` is known, while an empty array means details were fetched
/// and no available credits were returned. The backend may cap this list, so its length can be
/// less than `availableCount`.
pub credits: Option<Vec<RateLimitResetCredit>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RateLimitResetCredit {
/// Opaque backend identifier for this reset credit.
pub id: String,
pub reset_type: RateLimitResetType,
pub status: RateLimitResetCreditStatus,
/// Unix timestamp in seconds when the credit was granted.
#[ts(type = "number")]
pub granted_at: i64,
/// Unix timestamp in seconds when the credit expires, or `null` if it does not expire.
#[ts(type = "number | null")]
pub expires_at: Option<i64>,
/// Backend-provided display title for this credit, or `null` when unavailable.
pub title: Option<String>,
/// Backend-provided display description for this credit, or `null` when unavailable.
pub description: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/", rename_all = "camelCase")]
pub enum RateLimitResetType {
CodexRateLimits,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/", rename_all = "camelCase")]
pub enum RateLimitResetCreditStatus {
Available,
Redeeming,
Redeemed,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -293,6 +339,10 @@ pub struct ConsumeAccountRateLimitResetCreditParams {
/// Identifies one logical reset attempt. A UUID is recommended; reuse the same value when
/// retrying that attempt.
pub idempotency_key: String,
/// Opaque reset-credit identifier to redeem. When omitted, the backend selects the next
/// available credit.
#[ts(optional = nullable)]
pub credit_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -567,6 +567,17 @@ pub enum PluginInstallPolicy {
InstalledByDefault,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum PluginInstallPolicySource {
#[serde(rename = "WORKSPACE_SETTING")]
#[ts(rename = "WORKSPACE_SETTING")]
WorkspaceSetting,
#[serde(rename = "IMPLICIT_CANONICAL_APP")]
#[ts(rename = "IMPLICIT_CANONICAL_APP")]
ImplicitCanonicalApp,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum PluginAuthPolicy {
@@ -613,6 +624,7 @@ pub struct PluginSummary {
pub installed: bool,
pub enabled: bool,
pub install_policy: PluginInstallPolicy,
pub install_policy_source: Option<PluginInstallPolicySource>,
pub auth_policy: PluginAuthPolicy,
/// Availability state for installing and using the plugin.
#[serde(default)]
@@ -70,6 +70,10 @@ pub struct ThreadRealtimeStartParams {
/// them automatically. Defaults to false.
#[ts(optional = nullable)]
pub client_managed_handoffs: Option<bool>,
/// Routes any transcript tail remaining at session end through Codex. Defaults to false.
/// TODO: Remove this rollout knob once transcript-tail flushing is always enabled.
#[ts(optional = nullable)]
pub flush_transcript_tail_on_session_end: Option<bool>,
/// Sends automatic Codex responses as realtime conversation items instead of handoff appends.
#[ts(optional = nullable)]
pub codex_responses_as_items: Option<bool>,
@@ -3650,6 +3650,7 @@ fn plugin_share_list_response_serializes_share_items() {
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
install_policy_source: Some(PluginInstallPolicySource::WorkspaceSetting),
auth_policy: PluginAuthPolicy::OnUse,
availability: PluginAvailability::Available,
interface: None,
@@ -3672,6 +3673,7 @@ fn plugin_share_list_response_serializes_share_items() {
"installed": false,
"enabled": false,
"installPolicy": "AVAILABLE",
"installPolicySource": "WORKSPACE_SETTING",
"authPolicy": "ON_USE",
"availability": "AVAILABLE",
"interface": null,
@@ -685,7 +685,7 @@ pub struct ThreadIncrementElicitationParams {
#[ts(export_to = "v2/")]
pub struct ThreadIncrementElicitationResponse {
/// Current out-of-band elicitation count after the increment.
pub count: u64,
pub count: i64,
/// Whether timeout accounting is paused after applying the increment.
pub paused: bool,
}
@@ -705,7 +705,7 @@ pub struct ThreadDecrementElicitationParams {
#[ts(export_to = "v2/")]
pub struct ThreadDecrementElicitationResponse {
/// Current out-of-band elicitation count after the decrement.
pub count: u64,
pub count: i64,
/// Whether timeout accounting remains paused after applying the decrement.
pub paused: bool,
}
+35 -8
View File
@@ -213,8 +213,8 @@ Example with notification opt-out:
- `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present.
- `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists.
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, the remote marketplace `version` and locally materialized `localVersion` when available, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**).
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, nullable remote install-policy provenance in `installPolicySource` (`WORKSPACE_SETTING` or `IMPLICIT_CANONICAL_APP`), the remote marketplace `version` and locally materialized `localVersion` when available, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Remote rows include nullable `installPolicySource`; local rows return `null`. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**).
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Remote plugin details expose the canonical `shareUrl` supplied by the remote catalog when available; it is `null` for local plugins or when the catalog omits it. This field is separate from `summary.shareContext`, which continues to describe user and workspace sharing state. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**).
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
- `skills/changed` — notification emitted when watched local skill files change.
@@ -1929,8 +1929,8 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`.
- `account/logout` — sign out; triggers `account/updated`.
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available.
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the number of earned rate-limit resets currently available. Rate-limit updates arrive via `account/rateLimits/updated` (notify); the reset count is snapshot-only.
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key.
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the earned rate-limit resets currently available, including expiry details when provided by the backend. Rate-limit updates arrive via `account/rateLimits/updated` (notify); reset-credit data is snapshot-only.
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key, optionally selecting a reset-credit ID returned by `account/rateLimits/read`.
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets.
- `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available.
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
@@ -2032,7 +2032,30 @@ Field notes:
```json
{ "method": "account/rateLimits/read", "id": 7 }
{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null }, "rateLimitResetCredits": { "availableCount": 2 } } }
{
"id": 7,
"result": {
"rateLimits": {
"primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 },
"secondary": null,
"rateLimitReachedType": null
},
"rateLimitResetCredits": {
"availableCount": 2,
"credits": [
{
"id": "RateLimitResetCredit_1",
"resetType": "codexRateLimits",
"status": "available",
"grantedAt": 1781654400,
"expiresAt": 1784246400,
"title": "Full reset (Weekly + 5 hr)",
"description": "Ready to redeem"
}
]
}
}
}
{ "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } }
```
@@ -2043,23 +2066,27 @@ Field notes:
- `resetsAt` is a Unix timestamp (seconds) for the next reset.
- `rateLimitReachedType` identifies the backend-classified limit state when one has been reached.
- `individualLimit` describes the effective monthly credit limit when available. In an `account/rateLimits/read` response, `null` means no monthly limit is available. In a sparse `account/rateLimits/updated` notification, nullable account metadata may be unavailable and does not clear a previously observed value.
- `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`. Refetch `account/rateLimits/read` after consuming a reset.
- `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`.
- `rateLimitResetCredits.credits` is `null` when only the count is available. An empty array means details were fetched and no available credits were returned.
- The backend may cap `rateLimitResetCredits.credits`, so `availableCount` is the authoritative total and can be greater than the number of detail rows.
- Refetch `account/rateLimits/read` after consuming a reset.
### 8) Earned rate-limit resets (ChatGPT)
```json
{ "method": "account/rateLimitResetCredit/consume", "id": 8, "params": { "idempotencyKey": "8ae96ff3-3425-4f4c-8772-b6fd61502868" } }
{ "method": "account/rateLimitResetCredit/consume", "id": 8, "params": { "idempotencyKey": "8ae96ff3-3425-4f4c-8772-b6fd61502868", "creditId": "RateLimitResetCredit_1" } }
{ "id": 8, "result": { "outcome": "reset" } }
```
Field notes:
- `idempotencyKey` must be non-empty. A UUID is recommended for each logical redemption attempt; reuse the same value when retrying that attempt.
- `creditId` is optional. When provided, it must be a non-empty opaque ID returned by `account/rateLimits/read`; when omitted, the backend selects the next available credit.
- `reset` means a credit was consumed.
- `alreadyRedeemed` means the same redemption completed previously. Treat it as an idempotent success and refresh account limits.
- `nothingToReset` means there is no eligible rate-limit window to reset.
- `noCredit` means the account has no earned reset credits available.
- Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated windows from this response.
- Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated state from this response.
### 9) Workspace messages (ChatGPT)
+11 -1
View File
@@ -80,6 +80,7 @@ use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_config::ThreadConfigLoader;
use codex_core::check_execpolicy_for_warnings;
use codex_core::config::Config;
use codex_core::resolve_installation_id;
use codex_exec_server::EnvironmentManager;
@@ -349,7 +350,16 @@ impl InProcessClientHandle {
/// This function sends `initialize` followed by `initialized` before returning
/// the handle, so callers receive a ready-to-use runtime. If initialize fails,
/// the runtime is shut down and an `InvalidData` error is returned.
pub async fn start(args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
pub async fn start(mut args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
if let Ok(Some(err)) = check_execpolicy_for_warnings(&args.config.config_layer_stack).await {
let (path, range) = crate::exec_policy_warning_location(&err);
args.config_warnings.push(ConfigWarningNotification {
summary: "Error parsing rules; custom rules not applied.".to_string(),
details: Some(err.to_string()),
path,
range,
});
}
let initialize = args.initialize.clone();
let client = start_uninitialized(args).await?;
@@ -156,7 +156,10 @@ use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RateLimitResetCredit;
use codex_app_server_protocol::RateLimitResetCreditStatus;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use codex_app_server_protocol::RateLimitResetType;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
use codex_app_server_protocol::ReviewStartParams;
@@ -298,6 +301,8 @@ use codex_backend_client::CodexWorkspaceMessage as BackendWorkspaceMessage;
use codex_backend_client::CodexWorkspaceMessageType as BackendWorkspaceMessageType;
use codex_backend_client::CodexWorkspaceMessagesResponse as BackendWorkspaceMessagesResponse;
use codex_backend_client::ConsumeRateLimitResetCreditCode as BackendConsumeRateLimitResetCreditCode;
use codex_backend_client::RateLimitResetCreditDetails as BackendRateLimitResetCreditDetails;
use codex_backend_client::RateLimitResetCreditsDetails as BackendRateLimitResetCreditsDetails;
use codex_backend_client::RequestError as BackendRequestError;
use codex_backend_client::TokenUsageProfile;
use codex_chatgpt::connectors;
@@ -891,9 +891,11 @@ impl AccountRequestProcessor {
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
let response = client
.get_rate_limits_with_reset_credits()
.await
let (response, detailed_rate_limit_reset_credits) = tokio::join!(
client.get_rate_limits_with_reset_credits(),
Self::detailed_rate_limit_reset_credits(&client),
);
let response = response
.map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?;
if response.rate_limits.is_empty() {
return Err(internal_error(
@@ -920,6 +922,15 @@ impl AccountRequestProcessor {
.cloned()
.unwrap_or_else(|| response.rate_limits[0].clone());
let rate_limit_reset_credits = detailed_rate_limit_reset_credits.or_else(|| {
response
.rate_limit_reset_credits
.map(|summary| RateLimitResetCreditsSummary {
available_count: summary.available_count,
credits: None,
})
});
Ok(GetAccountRateLimitsResponse {
rate_limits: rate_limits.into(),
rate_limits_by_limit_id: Some(
@@ -928,11 +939,7 @@ impl AccountRequestProcessor {
.map(|(limit_id, snapshot)| (limit_id, snapshot.into()))
.collect(),
),
rate_limit_reset_credits: response.rate_limit_reset_credits.map(|summary| {
RateLimitResetCreditsSummary {
available_count: summary.available_count,
}
}),
rate_limit_reset_credits,
})
}
@@ -1,11 +1,47 @@
use super::*;
const RATE_LIMIT_RESET_REQUEST_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
const RATE_LIMIT_RESET_DETAILS_REQUEST_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 5);
#[cfg(debug_assertions)]
const RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR: &str =
"CODEX_TEST_RATE_LIMIT_RESET_REQUEST_TIMEOUT_MS";
impl AccountRequestProcessor {
pub(super) async fn detailed_rate_limit_reset_credits(
client: &BackendClient,
) -> Option<RateLimitResetCreditsSummary> {
let details = match tokio::time::timeout(
RATE_LIMIT_RESET_DETAILS_REQUEST_TIMEOUT,
client.list_rate_limit_reset_credits(),
)
.await
{
Ok(Ok(details)) => details,
Ok(Err(err)) => {
tracing::warn!(
"failed to fetch rate limit reset credit details; falling back to the usage response: {err}"
);
return None;
}
Err(_) => {
tracing::warn!(
"rate limit reset credit detail request timed out; falling back to the usage response"
);
return None;
}
};
match rate_limit_reset_credits_from_backend(details) {
Ok(summary) => Some(summary),
Err(err) => {
tracing::warn!(
"failed to parse rate limit reset credit details; falling back to the usage response: {err}"
);
None
}
}
}
pub(crate) async fn consume_account_rate_limit_reset_credit(
&self,
params: ConsumeAccountRateLimitResetCreditParams,
@@ -13,6 +49,9 @@ impl AccountRequestProcessor {
if params.idempotency_key.is_empty() {
return Err(invalid_request("idempotencyKey must not be empty"));
}
if params.credit_id.as_deref().is_some_and(str::is_empty) {
return Err(invalid_request("creditId must not be empty"));
}
let client = self.rate_limit_reset_backend_client().await?;
let request_timeout = RATE_LIMIT_RESET_REQUEST_TIMEOUT;
@@ -22,10 +61,20 @@ impl AccountRequestProcessor {
.and_then(|value| value.parse::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or(request_timeout);
let response = tokio::time::timeout(
request_timeout,
client.consume_rate_limit_reset_credit(&params.idempotency_key),
)
let response = tokio::time::timeout(request_timeout, async {
match params.credit_id.as_deref() {
Some(credit_id) => {
client
.consume_rate_limit_reset_credit_by_id(&params.idempotency_key, credit_id)
.await
}
None => {
client
.consume_rate_limit_reset_credit(&params.idempotency_key)
.await
}
}
})
.await
.map_err(|_| internal_error("rate limit reset consume timed out"))?
.map_err(|err| internal_error(format!("failed to consume rate limit reset: {err}")))?;
@@ -64,3 +113,56 @@ impl AccountRequestProcessor {
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))
}
}
fn rate_limit_reset_credits_from_backend(
details: BackendRateLimitResetCreditsDetails,
) -> Result<RateLimitResetCreditsSummary, String> {
let credits = details
.credits
.into_iter()
.map(rate_limit_reset_credit_from_backend)
.collect::<Result<Vec<_>, _>>()?;
Ok(RateLimitResetCreditsSummary {
available_count: details.available_count,
credits: Some(credits),
})
}
fn rate_limit_reset_credit_from_backend(
credit: BackendRateLimitResetCreditDetails,
) -> Result<RateLimitResetCredit, String> {
let reset_type = match credit.reset_type.as_str() {
"codex_rate_limits" => RateLimitResetType::CodexRateLimits,
_ => RateLimitResetType::Unknown,
};
let status = match credit.status.as_str() {
"available" => RateLimitResetCreditStatus::Available,
"redeeming" => RateLimitResetCreditStatus::Redeeming,
"redeemed" => RateLimitResetCreditStatus::Redeemed,
_ => RateLimitResetCreditStatus::Unknown,
};
let granted_at = rate_limit_reset_credit_timestamp(&credit.granted_at)
.map_err(|err| format!("invalid granted_at for credit `{}`: {err}", credit.id))?;
let expires_at = credit
.expires_at
.as_deref()
.map(rate_limit_reset_credit_timestamp)
.transpose()
.map_err(|err| format!("invalid expires_at for credit `{}`: {err}", credit.id))?;
Ok(RateLimitResetCredit {
id: credit.id,
reset_type,
status,
granted_at,
expires_at,
title: credit.title,
description: credit.description,
})
}
fn rate_limit_reset_credit_timestamp(timestamp: &str) -> Result<i64, String> {
DateTime::parse_from_rfc3339(timestamp)
.map(|timestamp| timestamp.timestamp())
.map_err(|err| format!("failed to parse timestamp `{timestamp}`: {err}"))
}
@@ -163,6 +163,7 @@ fn convert_configured_marketplace_plugin_to_plugin_summary(
share_context,
source: marketplace_plugin_source_to_info(plugin.source),
install_policy: plugin.policy.installation.into(),
install_policy_source: None,
auth_policy: plugin.policy.authentication.into(),
availability: PluginAvailability::Available,
interface: plugin.interface.map(local_plugin_interface_to_info),
@@ -1104,6 +1105,7 @@ impl PluginRequestProcessor {
installed: outcome.plugin.installed,
enabled: outcome.plugin.enabled,
install_policy: outcome.plugin.policy.installation.into(),
install_policy_source: None,
auth_policy: outcome.plugin.policy.authentication.into(),
availability: PluginAvailability::Available,
interface: outcome.plugin.interface.map(local_plugin_interface_to_info),
@@ -2180,6 +2182,7 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS
installed: summary.installed,
enabled: summary.enabled,
install_policy: summary.install_policy,
install_policy_source: summary.install_policy_source,
auth_policy: summary.auth_policy,
availability: summary.availability,
interface: summary.interface,
@@ -1002,6 +1002,9 @@ impl TurnRequestProcessor {
thread.as_ref(),
Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: params.client_managed_handoffs.unwrap_or(false),
flush_transcript_tail_on_session_end: params
.flush_transcript_tail_on_session_end
.unwrap_or(false),
codex_responses_as_items: params.codex_responses_as_items.unwrap_or(false),
codex_response_item_prefix: params.codex_response_item_prefix,
codex_response_handoff_prefix: params.codex_response_handoff_prefix,
@@ -111,6 +111,7 @@ use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnEnvironmentParams;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::WindowsSandboxSetupStartParams;
use codex_exec_server::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR;
@@ -216,15 +217,21 @@ impl TestAppServer {
Ok(app_server)
}
/// Returns the automatically selected test environment retained by this server.
///
/// Tests can use the environment to arrange target-native filesystem fixtures before starting
/// a thread. Returns an error unless this server was created with [`Self::new_with_auto_env`].
pub fn auto_env(&self) -> anyhow::Result<&TestEnv> {
self.auto_env
.as_ref()
.context("auto environment is unavailable; use TestAppServer::new_with_auto_env")
}
/// Returns app-server protocol parameters for the automatically selected
/// test environment. Returns an error unless this server was created with
/// [`Self::new_with_auto_env`].
pub fn auto_env_params(&self) -> anyhow::Result<TurnEnvironmentParams> {
let selection = self
.auto_env
.as_ref()
.context("auto environment is unavailable; use TestAppServer::new_with_auto_env")?
.selection();
let selection = self.auto_env()?.selection();
Ok(TurnEnvironmentParams {
environment_id: selection.environment_id.clone(),
cwd: selection.cwd.clone().into(),
@@ -1052,6 +1059,39 @@ impl TestAppServer {
self.send_request("turn/start", params).await
}
/// Start a turn and return its matching typed completion notification.
pub async fn start_turn_and_wait_for_completion(
&mut self,
params: TurnStartParams,
) -> anyhow::Result<TurnCompletedNotification> {
let thread_id = params.thread_id.clone();
let request_id = self.send_turn_start_request(params).await?;
let response = self
.read_stream_until_response_message(RequestId::Integer(request_id))
.await?;
let TurnStartResponse { turn } = crate::to_response(response)?;
let notification = self
.read_stream_until_matching_notification(
"turn/completed for started turn",
|notification| {
notification.method == "turn/completed"
&& notification.params.as_ref().is_some_and(|params| {
serde_json::from_value::<TurnCompletedNotification>(params.clone())
.is_ok_and(|completed| {
completed.thread_id == thread_id && completed.turn.id == turn.id
})
})
},
)
.await?;
let params = notification
.params
.context("turn/completed notification must include params")?;
let completed = serde_json::from_value(params)
.context("failed to deserialize turn/completed notification")?;
Ok(completed)
}
/// Send a `thread/inject_items` JSON-RPC request (v2).
pub async fn send_thread_inject_items_request(
&mut self,
@@ -82,6 +82,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R
let request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -198,6 +199,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<
let request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -60,6 +60,7 @@ mod review;
mod safety_check_downgrade;
#[cfg(not(target_os = "windows"))]
mod selected_capability_stack;
mod selected_environment;
mod skills_list;
mod sleep;
mod thread_archive;
@@ -9,6 +9,7 @@ use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInstallPolicySource;
use codex_app_server_protocol::PluginInstalledParams;
use codex_app_server_protocol::PluginInstalledResponse;
use codex_app_server_protocol::PluginListMarketplaceKind;
@@ -196,6 +197,12 @@ enabled = true
]
);
assert_eq!(response.marketplace_load_errors, Vec::new());
assert!(
response.marketplaces[0]
.plugins
.iter()
.all(|plugin| plugin.install_policy_source.is_none())
);
Ok(())
}
@@ -289,11 +296,17 @@ enabled = true
remote_marketplace
.plugins
.iter()
.map(|plugin| plugin.id.clone())
.map(|plugin| (plugin.id.clone(), plugin.install_policy_source))
.collect::<Vec<_>>(),
vec![
"linear@openai-curated-remote".to_string(),
"remote-only@openai-curated-remote".to_string(),
(
"linear@openai-curated-remote".to_string(),
Some(PluginInstallPolicySource::WorkspaceSetting),
),
(
"remote-only@openai-curated-remote".to_string(),
Some(PluginInstallPolicySource::WorkspaceSetting),
),
]
);
assert_eq!(response.marketplace_load_errors, Vec::new());
@@ -462,6 +475,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
install_policy_source: None,
auth_policy: PluginAuthPolicy::OnInstall,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: None,
@@ -754,6 +768,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
install_policy_source: None,
auth_policy: PluginAuthPolicy::OnInstall,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: Some(codex_app_server_protocol::PluginInterface {
@@ -794,6 +809,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
install_policy_source: None,
auth_policy: PluginAuthPolicy::OnInstall,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: None,
@@ -1683,6 +1699,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"installation_policy_source": "IMPLICIT_CANONICAL_APP",
"authentication_policy": "ON_USE",
"status": "ENABLED",
"release": {
@@ -1722,6 +1739,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"installation_policy_source": "WORKSPACE_SETTING",
"authentication_policy": "ON_USE",
"status": "ENABLED",
"release": {
@@ -1845,6 +1863,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
);
assert_eq!(remote_marketplace.plugins[0].installed, true);
assert_eq!(remote_marketplace.plugins[0].enabled, true);
assert_eq!(
remote_marketplace.plugins[0].install_policy_source,
Some(PluginInstallPolicySource::ImplicitCanonicalApp)
);
assert_eq!(
remote_marketplace.plugins[0].availability,
codex_app_server_protocol::PluginAvailability::Available
@@ -1880,6 +1902,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
let cached_catalog: serde_json::Value =
serde_json::from_slice(&std::fs::read(&cache_files[0])?)?;
assert_eq!(cached_catalog["schema_version"], serde_json::json!(1));
assert_eq!(
cached_catalog["plugins"][0]["installation_policy_source"],
serde_json::json!("IMPLICIT_CANONICAL_APP")
);
assert_eq!(
cached_catalog["plugins"][0]["release"]["interface"]["default_prompts"],
serde_json::json!(["Create a Linear issue", "Review my Linear projects"])
@@ -3938,6 +3964,7 @@ fn remote_installed_plugin_body_with_optional_app_manifest(
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"installation_policy_source": "WORKSPACE_SETTING",
"authentication_policy": "ON_USE",
"release": {{
"version": "{release_version}",
@@ -25,6 +25,7 @@ use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginAvailability;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInstallPolicySource;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginShareDiscoverability;
@@ -144,6 +145,7 @@ apps = true
"name": "example-plugin",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"installation_policy_source": "IMPLICIT_CANONICAL_APP",
"authentication_policy": "ON_USE",
"release": {
"version": "1.2.1",
@@ -267,6 +269,10 @@ apps = true
assert_eq!(response.plugin.summary.name, "example-plugin");
assert_eq!(response.plugin.summary.source, PluginSource::Remote);
assert_eq!(response.plugin.summary.share_context, None);
assert_eq!(
response.plugin.summary.install_policy_source,
Some(PluginInstallPolicySource::ImplicitCanonicalApp)
);
assert_eq!(
response
.plugin
@@ -179,6 +179,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
installed: true,
enabled: true,
install_policy: PluginInstallPolicy::Available,
install_policy_source: None,
auth_policy: PluginAuthPolicy::OnUse,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: Some(expected_plugin_interface()),
@@ -583,6 +584,7 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
installed: true,
enabled: true,
install_policy: PluginInstallPolicy::Available,
install_policy_source: None,
auth_policy: PluginAuthPolicy::OnUse,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: Some(expected_plugin_interface()),
@@ -1184,6 +1186,7 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
installed: true,
enabled: true,
install_policy: PluginInstallPolicy::Available,
install_policy_source: None,
auth_policy: PluginAuthPolicy::OnUse,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: Some(expected_plugin_interface()),
@@ -44,6 +44,7 @@ async fn consume_rate_limit_reset_credit_requires_chatgpt_auth() -> Result<()> {
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: "request-1".to_string(),
credit_id: None,
},
)
.await?;
@@ -120,6 +121,44 @@ async fn consume_account_rate_limit_reset_credit_maps_backend_outcomes() -> Resu
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_forwards_selected_credit_id() -> Result<()> {
let (codex_home, server) = chatgpt_test_context().await?;
Mock::given(method("POST"))
.and(path("/api/codex/rate-limit-reset-credits/consume"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.and(body_json(json!({
"redeem_request_id": "request-selected",
"credit_id": "credit-123",
})))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({ "code": "reset", "windows_reset": 2 })),
)
.expect(1)
.mount(&server)
.await;
let mut mcp = initialized_app_server(codex_home.path()).await?;
let request_id = mcp
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: "request-selected".to_string(),
credit_id: Some("credit-123".to_string()),
},
)
.await?;
assert_eq!(
read_response::<ConsumeAccountRateLimitResetCreditResponse>(&mut mcp, request_id).await?,
ConsumeAccountRateLimitResetCreditResponse {
outcome: ConsumeAccountRateLimitResetCreditOutcome::Reset,
}
);
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key() -> Result<()> {
let (codex_home, _server) = chatgpt_test_context().await?;
@@ -129,6 +168,7 @@ async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key()
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: String::new(),
credit_id: None,
},
)
.await?;
@@ -139,6 +179,26 @@ async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key()
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_rejects_empty_credit_id() -> Result<()> {
let (codex_home, _server) = chatgpt_test_context().await?;
let mut mcp = initialized_app_server(codex_home.path()).await?;
let request_id = mcp
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: "request-1".to_string(),
credit_id: Some(String::new()),
},
)
.await?;
let error = read_error_response(&mut mcp, request_id).await?;
assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE);
assert_eq!(error.error.message, "creditId must not be empty");
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_surfaces_backend_failure() -> Result<()> {
let (codex_home, server) = chatgpt_test_context().await?;
@@ -244,6 +304,7 @@ async fn send_consume_reset_credit(mcp: &mut TestAppServer, idempotency_key: &st
mcp.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: idempotency_key.to_string(),
credit_id: None,
},
)
.await
@@ -10,7 +10,10 @@ use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::RateLimitReachedType;
use codex_app_server_protocol::RateLimitResetCredit;
use codex_app_server_protocol::RateLimitResetCreditStatus;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use codex_app_server_protocol::RateLimitResetType;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
use codex_app_server_protocol::RequestId;
@@ -109,6 +112,16 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
let secondary_reset_timestamp = chrono::DateTime::parse_from_rfc3339("2025-01-01T01:00:00Z")
.expect("parse secondary reset timestamp")
.timestamp();
let reset_credit_granted_at = chrono::DateTime::parse_from_rfc3339("2026-06-17T00:00:00Z")
.expect("parse reset credit grant timestamp")
.timestamp();
let reset_credit_expires_at = chrono::DateTime::parse_from_rfc3339("2026-07-17T00:00:00Z")
.expect("parse reset credit expiry timestamp")
.timestamp();
let second_reset_credit_granted_at =
chrono::DateTime::parse_from_rfc3339("2026-06-18T00:00:00Z")
.expect("parse second reset credit grant timestamp")
.timestamp();
let response_body = json!({
"plan_type": "pro",
"rate_limit": {
@@ -170,6 +183,35 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/codex/rate-limit-reset-credits"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"credits": [
{
"id": "credit-1",
"reset_type": "codex_rate_limits",
"status": "available",
"granted_at": "2026-06-17T00:00:00Z",
"expires_at": "2026-07-17T00:00:00Z",
"title": "Full reset (Weekly + 5 hr)",
"description": "Ready to redeem"
},
{
"id": "credit-2",
"reset_type": "future_reset_type",
"status": "future_status",
"granted_at": "2026-06-18T00:00:00Z",
"expires_at": null
}
],
"available_count": 2,
"total_earned_count": 4
})))
.expect(1)
.mount(&server)
.await;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
@@ -260,13 +302,98 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
.into_iter()
.collect(),
),
rate_limit_reset_credits: Some(RateLimitResetCreditsSummary { available_count: 3 }),
rate_limit_reset_credits: Some(RateLimitResetCreditsSummary {
available_count: 2,
credits: Some(vec![
RateLimitResetCredit {
id: "credit-1".to_string(),
reset_type: RateLimitResetType::CodexRateLimits,
status: RateLimitResetCreditStatus::Available,
granted_at: reset_credit_granted_at,
expires_at: Some(reset_credit_expires_at),
title: Some("Full reset (Weekly + 5 hr)".to_string()),
description: Some("Ready to redeem".to_string()),
},
RateLimitResetCredit {
id: "credit-2".to_string(),
reset_type: RateLimitResetType::Unknown,
status: RateLimitResetCreditStatus::Unknown,
granted_at: second_reset_credit_granted_at,
expires_at: None,
title: None,
description: None,
},
]),
}),
};
assert_eq!(received, expected);
Ok(())
}
#[tokio::test]
async fn get_account_rate_limits_preserves_count_when_reset_credit_details_fail() -> Result<()> {
let codex_home = TempDir::new()?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.plan_type("pro"),
AuthCredentialsStoreMode::File,
)?;
let server = MockServer::start().await;
write_chatgpt_base_url(codex_home.path(), &server.uri())?;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plan_type": "pro",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 42,
"limit_window_seconds": 3600,
"reset_after_seconds": 120,
"reset_at": 1735689720
}
},
"rate_limit_reset_credits": { "available_count": 3 }
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/codex/rate-limit-reset-credits"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.expect(1)
.mount(&server)
.await;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_get_account_rate_limits_request().await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: GetAccountRateLimitsResponse = to_response(response)?;
assert_eq!(
received.rate_limit_reset_credits,
Some(RateLimitResetCreditsSummary {
available_count: 3,
credits: None,
})
);
Ok(())
}
#[tokio::test]
async fn send_add_credits_nudge_email_requires_auth() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -349,6 +349,7 @@ impl RealtimeE2eHarness {
.mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs,
flush_transcript_tail_on_session_end: None,
thread_id: self.thread_id.clone(),
codex_response_item_prefix: codex_responses_as_items
.unwrap_or(false)
@@ -410,6 +411,7 @@ impl RealtimeE2eHarness {
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
thread_id: self.thread_id.clone(),
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_response_item_prefix: codex_responses_as_items
.unwrap_or(false)
.then(|| RESPONSE_ITEM_PREFIX.to_string()),
@@ -672,6 +674,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
let start_request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -963,6 +966,7 @@ async fn realtime_start_can_skip_startup_context() -> Result<()> {
let start_request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1061,6 +1065,7 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript
let start_request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1242,6 +1247,7 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> {
let start_request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -1346,6 +1352,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> {
let start_request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -2701,6 +2708,7 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> {
let start_request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -2767,6 +2775,7 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> {
let start_request_id = mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -0,0 +1,252 @@
use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use app_test_support::PathBufExt;
use app_test_support::TestAppServer;
use app_test_support::to_response;
use app_test_support::write_mock_responses_config_toml;
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::UserInput as V2UserInput;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const AGENTS_INSTRUCTIONS: &str = "selected environment workspace instructions";
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
fn text_turn_params(thread_id: String, prompt: &str) -> TurnStartParams {
TurnStartParams {
thread_id,
input: vec![V2UserInput::Text {
text: prompt.to_string(),
text_elements: Vec::new(),
}],
..Default::default()
}
}
#[tokio::test]
async fn thread_start_reports_selected_environment_metadata() -> Result<()> {
let server = responses::start_mock_server().await;
let codex_home = TempDir::new()?;
write_mock_responses_config_toml(
codex_home.path(),
&server.uri(),
&BTreeMap::new(),
/*auto_compact_limit*/ 100_000,
/*requires_openai_auth*/ None,
"mock_provider",
"compact",
)?;
let mut app_server = TestAppServer::new_with_auto_env(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??;
let request_id = app_server
.send_thread_start_request_with_auto_env(ThreadStartParams::default())
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadStartResponse {
cwd,
runtime_workspace_roots,
active_permission_profile,
..
} = to_response(response)?;
let host_cwd = codex_home.path().to_path_buf().abs().canonicalize()?;
let cwd = cwd.canonicalize()?;
let runtime_workspace_roots = runtime_workspace_roots
.into_iter()
.map(|root| root.canonicalize())
.collect::<std::io::Result<Vec<_>>>()?;
assert_eq!(
(cwd, runtime_workspace_roots, active_permission_profile),
(
// TODO(anp): Return the selected environment's native cwd from thread/start.
host_cwd.clone(),
// TODO(anp): Derive runtime workspace roots from the selected remote environment.
vec![host_cwd],
// TODO(anp): Report the implicit built-in permission profile instead of None.
None,
)
);
Ok(())
}
#[tokio::test]
async fn thread_start_reports_selected_environment_instruction_source() -> Result<()> {
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "done"),
responses::ev_completed("resp-1"),
]),
)
.await;
let codex_home = TempDir::new()?;
write_mock_responses_config_toml(
codex_home.path(),
&server.uri(),
&BTreeMap::new(),
/*auto_compact_limit*/ 100_000,
/*requires_openai_auth*/ None,
"mock_provider",
"compact",
)?;
let mut app_server = TestAppServer::new_with_auto_env(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??;
let (agents_source, environment_cwd) = {
let auto_env = app_server.auto_env()?;
let environment_cwd = auto_env.selection().cwd.clone();
let agents_source = environment_cwd.join("AGENTS.md")?;
auto_env
.environment()
.get_filesystem()
.write_file(
&agents_source,
AGENTS_INSTRUCTIONS.as_bytes().to_vec(),
/*sandbox*/ None,
)
.await?;
(agents_source, environment_cwd)
};
let request_id = app_server
.send_thread_start_request_with_auto_env(ThreadStartParams::default())
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: ThreadStartResponse = to_response(response)?;
assert_eq!(response.instruction_sources, vec![agents_source.into()]);
timeout(
DEFAULT_READ_TIMEOUT,
app_server.start_turn_and_wait_for_completion(text_turn_params(
response.thread.id,
"inspect workspace instructions",
)),
)
.await??;
let user_context = response_mock.single_request().message_input_texts("user");
let instructions = user_context
.iter()
.find(|text| text.starts_with("# AGENTS.md instructions"))
.context("selected environment instructions should be model visible")?;
let expected_instructions = format!(
"# AGENTS.md instructions for {}\n\n<INSTRUCTIONS>\n{AGENTS_INSTRUCTIONS}\n</INSTRUCTIONS>",
environment_cwd.inferred_native_path_string()
);
assert_eq!(instructions, &expected_instructions);
Ok(())
}
#[tokio::test]
async fn turn_model_context_uses_selected_environment() -> Result<()> {
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "done"),
responses::ev_completed("resp-1"),
]),
)
.await;
let codex_home = TempDir::new()?;
write_mock_responses_config_toml(
codex_home.path(),
&server.uri(),
&BTreeMap::new(),
/*auto_compact_limit*/ 100_000,
/*requires_openai_auth*/ None,
"mock_provider",
"compact",
)?;
let mut app_server = TestAppServer::new_with_auto_env(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??;
let (environment_cwd, environment_shell) = {
let auto_env = app_server.auto_env()?;
(
auto_env.selection().cwd.clone(),
auto_env.environment().info().await?.shell.name,
)
};
let request_id = app_server
.send_thread_start_request_with_auto_env(ThreadStartParams::default())
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadStartResponse {
thread,
runtime_workspace_roots,
..
} = to_response(response)?;
timeout(
DEFAULT_READ_TIMEOUT,
app_server.start_turn_and_wait_for_completion(text_turn_params(
thread.id,
"inspect the selected environment",
)),
)
.await??;
let user_context = response_mock.single_request().message_input_texts("user");
let environment_context = user_context
.iter()
.find(|text| text.starts_with("<environment_context>"))
.context("selected environment context should be model visible")?;
let shell = environment_context
.lines()
.find(|line| line.trim_start().starts_with("<shell>"))
.map(str::trim)
.map(str::to_string);
let cwd = environment_context
.lines()
.find(|line| line.trim_start().starts_with("<cwd>"))
.map(str::trim)
.map(str::to_string);
assert_eq!(
(shell, cwd),
(
Some(format!("<shell>{environment_shell}</shell>")),
Some(format!(
"<cwd>{}</cwd>",
environment_cwd.inferred_native_path_string()
)),
)
);
let [runtime_workspace_root] = runtime_workspace_roots.as_slice() else {
anyhow::bail!("expected one runtime workspace root");
};
let expected_workspace_roots = format!(
"<workspace_roots><root>{}</root></workspace_roots>",
runtime_workspace_root.as_path().display()
);
// TODO(anp): Derive model-visible workspace roots from the selected remote environment and
// render them using its native path convention.
assert!(environment_context.contains(&expected_workspace_roots));
Ok(())
}
+1 -1
View File
@@ -19,7 +19,7 @@ serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
codex-backend-openapi-models = { path = "../codex-backend-openapi-models" }
codex-api = { workspace = true }
codex-client = { workspace = true }
codex-http-client = { workspace = true }
codex-login = { workspace = true }
codex-model-provider = { workspace = true }
codex-protocol = { workspace = true }
+2 -2
View File
@@ -9,8 +9,8 @@ use crate::types::TokenUsageProfile;
use crate::types::TurnAttemptsSiblingTurnsResponse;
use anyhow::Result;
use codex_api::SharedAuthProvider;
use codex_client::build_reqwest_client_with_custom_ca;
use codex_client::with_chatgpt_cloudflare_cookie_store;
use codex_http_client::build_reqwest_client_with_custom_ca;
use codex_http_client::with_chatgpt_cloudflare_cookie_store;
use codex_login::CodexAuth;
use codex_login::default_client::get_codex_user_agent;
use codex_protocol::account::PlanType as AccountPlanType;
@@ -3,6 +3,7 @@
use super::Client;
use super::PathStyle;
use crate::types::ConsumeRateLimitResetCreditResponse;
use crate::types::RateLimitResetCreditsDetails;
use crate::types::RateLimitStatusWithResetCredits;
use crate::types::RateLimitsWithResetCredits;
use anyhow::Result;
@@ -13,6 +14,8 @@ use serde::Serialize;
#[derive(Serialize)]
struct ConsumeRateLimitResetCreditRequest<'a> {
redeem_request_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
credit_id: Option<&'a str>,
}
impl Client {
@@ -31,9 +34,34 @@ impl Client {
self.decode_json(&url, &ct, &body)
}
pub async fn list_rate_limit_reset_credits(&self) -> Result<RateLimitResetCreditsDetails> {
let url = self.rate_limit_reset_credits_url();
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
self.decode_json(&url, &ct, &body)
}
pub async fn consume_rate_limit_reset_credit(
&self,
redeem_request_id: &str,
) -> Result<ConsumeRateLimitResetCreditResponse> {
self.consume_rate_limit_reset_credit_request(redeem_request_id, /*credit_id*/ None)
.await
}
pub async fn consume_rate_limit_reset_credit_by_id(
&self,
redeem_request_id: &str,
credit_id: &str,
) -> Result<ConsumeRateLimitResetCreditResponse> {
self.consume_rate_limit_reset_credit_request(redeem_request_id, Some(credit_id))
.await
}
async fn consume_rate_limit_reset_credit_request(
&self,
redeem_request_id: &str,
credit_id: Option<&str>,
) -> Result<ConsumeRateLimitResetCreditResponse> {
let url = self.consume_rate_limit_reset_credit_url();
let req = self
@@ -41,7 +69,10 @@ impl Client {
.post(&url)
.headers(self.headers())
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&ConsumeRateLimitResetCreditRequest { redeem_request_id });
.json(&ConsumeRateLimitResetCreditRequest {
redeem_request_id,
credit_id,
});
let (body, ct) = self.exec_request(req, "POST", &url).await?;
self.decode_json(&url, &ct, &body)
}
@@ -53,6 +84,17 @@ impl Client {
}
}
fn rate_limit_reset_credits_url(&self) -> String {
match self.path_style {
PathStyle::CodexApi => {
format!("{}/api/codex/rate-limit-reset-credits", self.base_url)
}
PathStyle::ChatGptApi => {
format!("{}/wham/rate-limit-reset-credits", self.base_url)
}
}
}
fn consume_rate_limit_reset_credit_url(&self) -> String {
match self.path_style {
PathStyle::CodexApi => {
@@ -1,5 +1,7 @@
use super::*;
use crate::types::ConsumeRateLimitResetCreditCode;
use crate::types::RateLimitResetCreditDetails;
use crate::types::RateLimitResetCreditsDetails;
use crate::types::RateLimitResetCreditsSummary;
use pretty_assertions::assert_eq;
@@ -9,6 +11,10 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
test_client("https://example.test", PathStyle::CodexApi).rate_limit_status_url(),
"https://example.test/api/codex/usage"
);
assert_eq!(
test_client("https://example.test", PathStyle::CodexApi).rate_limit_reset_credits_url(),
"https://example.test/api/codex/rate-limit-reset-credits"
);
assert_eq!(
test_client("https://example.test", PathStyle::CodexApi)
.consume_rate_limit_reset_credit_url(),
@@ -19,6 +25,11 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
.rate_limit_status_url(),
"https://chatgpt.com/backend-api/wham/usage"
);
assert_eq!(
test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi)
.rate_limit_reset_credits_url(),
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"
);
assert_eq!(
test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi)
.consume_rate_limit_reset_credit_url(),
@@ -28,10 +39,22 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
assert_eq!(
serde_json::to_value(ConsumeRateLimitResetCreditRequest {
redeem_request_id: "redeem-123",
credit_id: None,
})
.unwrap(),
serde_json::json!({ "redeem_request_id": "redeem-123" })
);
assert_eq!(
serde_json::to_value(ConsumeRateLimitResetCreditRequest {
redeem_request_id: "redeem-456",
credit_id: Some("credit-123"),
})
.unwrap(),
serde_json::json!({
"redeem_request_id": "redeem-456",
"credit_id": "credit-123",
})
);
let status: RateLimitStatusWithResetCredits = serde_json::from_value(serde_json::json!({
"plan_type": "plus",
@@ -43,6 +66,60 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
Some(RateLimitResetCreditsSummary { available_count: 3 })
);
let details: RateLimitResetCreditsDetails = serde_json::from_value(serde_json::json!({
"credits": [
{
"id": "credit-1",
"reset_type": "codex_rate_limits",
"status": "available",
"granted_at": "2026-06-17T00:00:00Z",
"expires_at": "2026-07-17T00:00:00Z",
"redeem_started_at": null,
"redeemed_at": null,
"profile_image_url": "https://example.test/avatar.png",
"profile_user_id": "@friend",
"title": "Full reset (Weekly + 5 hr)",
"description": "Ready to redeem"
},
{
"id": "credit-2",
"reset_type": "codex_rate_limits",
"status": "available",
"granted_at": "2026-06-18T00:00:00Z",
"expires_at": null
}
],
"available_count": 2,
"total_earned_count": 4
}))
.unwrap();
assert_eq!(
details,
RateLimitResetCreditsDetails {
credits: vec![
RateLimitResetCreditDetails {
id: "credit-1".to_string(),
reset_type: "codex_rate_limits".to_string(),
status: "available".to_string(),
granted_at: "2026-06-17T00:00:00Z".to_string(),
expires_at: Some("2026-07-17T00:00:00Z".to_string()),
title: Some("Full reset (Weekly + 5 hr)".to_string()),
description: Some("Ready to redeem".to_string()),
},
RateLimitResetCreditDetails {
id: "credit-2".to_string(),
reset_type: "codex_rate_limits".to_string(),
status: "available".to_string(),
granted_at: "2026-06-18T00:00:00Z".to_string(),
expires_at: None,
title: None,
description: None,
},
],
available_count: 2,
}
);
let response: ConsumeRateLimitResetCreditResponse = serde_json::from_value(serde_json::json!({
"code": "reset",
"credit": { "id": "ignored-by-cli" },
+2
View File
@@ -18,6 +18,8 @@ pub use types::DeliveredConfigToml;
pub use types::DeliveredRequirementsToml;
pub use types::DeliveredTomlFragment;
pub use types::PaginatedListTaskListItem;
pub use types::RateLimitResetCreditDetails;
pub use types::RateLimitResetCreditsDetails;
pub use types::RateLimitResetCreditsSummary;
pub use types::RateLimitsWithResetCredits;
pub use types::TaskListItem;
+17
View File
@@ -23,6 +23,23 @@ pub struct RateLimitResetCreditsSummary {
pub available_count: i64,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct RateLimitResetCreditsDetails {
pub credits: Vec<RateLimitResetCreditDetails>,
pub available_count: i64,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct RateLimitResetCreditDetails {
pub id: String,
pub reset_type: String,
pub status: String,
pub granted_at: String,
pub expires_at: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RateLimitsWithResetCredits {
pub rate_limits: Vec<RateLimitSnapshot>,
+1 -1
View File
@@ -16,7 +16,7 @@ workspace = true
anyhow = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
clap = { workspace = true, features = ["derive"] }
codex-client = { workspace = true }
codex-http-client = { workspace = true }
codex-cloud-tasks-client = { workspace = true }
# TODO: codex-cloud-tasks-mock-client should be in dev-dependencies.
codex-cloud-tasks-mock-client = { workspace = true }
+1 -1
View File
@@ -1,4 +1,4 @@
use codex_client::build_reqwest_client_with_custom_ca;
use codex_http_client::build_reqwest_client_with_custom_ca;
use reqwest::header::CONTENT_TYPE;
use reqwest::header::HeaderMap;
use std::collections::HashMap;
+1
View File
@@ -28,3 +28,4 @@ codex-protocol = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
pretty_assertions = { workspace = true }
serde_json = { workspace = true }
tempfile = { workspace = true }
+12 -13
View File
@@ -504,13 +504,17 @@ return;
next_callback_event(&mut events_rx).await,
CallbackEvent::Started("tool_call_slow".to_string())
);
assert_eq!(
let mut closure_events = vec![
next_callback_event(&mut events_rx).await,
CallbackEvent::Cancelled("tool_call_slow".to_string())
);
assert_eq!(
next_callback_event(&mut events_rx).await,
CallbackEvent::CellClosed(running_cell_id.clone())
];
closure_events.sort_by(|left, right| format!("{left:?}").cmp(&format!("{right:?}")));
assert_eq!(
closure_events,
vec![
CallbackEvent::Cancelled("tool_call_slow".to_string()),
CallbackEvent::CellClosed(running_cell_id.clone()),
]
);
assert_eq!(
wait_task
@@ -677,12 +681,9 @@ async fn oversized_initial_response_does_not_close_the_shared_host() {
async fn child_process_loss_cleans_up_and_rebuilds_the_shared_host() {
let host_program =
codex_utils_cargo_bin::cargo_bin("codex-code-mode-host").expect("host binary");
let proxy_dir =
std::env::temp_dir().join(format!("codex-code-mode-host-loss-{}", std::process::id()));
let proxy_program = proxy_dir.join("host-proxy.sh");
let pid_path = proxy_dir.join("host.pid");
let _ = std::fs::remove_dir_all(&proxy_dir);
std::fs::create_dir_all(&proxy_dir).expect("create host proxy directory");
let proxy_dir = tempfile::tempdir().expect("create host proxy directory");
let proxy_program = proxy_dir.path().join("host-proxy.sh");
let pid_path = proxy_dir.path().join("host.pid");
std::fs::write(
&proxy_program,
format!(
@@ -869,6 +870,4 @@ async fn child_process_loss_cleans_up_and_rebuilds_the_shared_host() {
events_a.try_recv(),
Err(mpsc::error::TryRecvError::Empty)
));
std::fs::remove_dir_all(proxy_dir).expect("remove host proxy directory");
}
+3
View File
@@ -3,6 +3,7 @@ mod remote_session;
mod runtime;
mod service;
mod session_runtime;
mod v8_init;
pub(crate) type TaskFailureHandler = std::sync::Arc<dyn Fn(String) + Send + Sync>;
@@ -12,3 +13,5 @@ pub use remote_session::ProcessOwnedCodeModeSessionProvider;
pub use service::InProcessCodeModeSession;
pub use service::InProcessCodeModeSessionProvider;
pub use service::NoopCodeModeSessionDelegate;
pub use v8_init::V8JitMode;
pub use v8_init::initialize_v8;
+2 -18
View File
@@ -7,7 +7,6 @@ mod value;
use std::collections::HashMap;
use std::panic::AssertUnwindSafe;
use std::panic::catch_unwind;
use std::sync::OnceLock;
use std::sync::mpsc as std_mpsc;
use std::thread;
@@ -21,6 +20,7 @@ use serde_json::Value as JsonValue;
use tokio::sync::mpsc;
use crate::TaskFailureHandler;
use crate::v8_init::ensure_v8_initialized;
const EXIT_SENTINEL: &str = "__codex_code_mode_exit__";
@@ -84,7 +84,7 @@ pub(crate) fn spawn_runtime(
),
String,
> {
initialize_v8()?;
ensure_v8_initialized()?;
let (command_tx, command_rx) = std_mpsc::channel();
let (control_tx, control_rx) = std_mpsc::channel();
@@ -165,22 +165,6 @@ pub(super) enum CompletionState {
},
}
fn initialize_v8() -> Result<(), String> {
static PLATFORM: OnceLock<Result<v8::SharedRef<v8::Platform>, String>> = OnceLock::new();
match PLATFORM.get_or_init(|| {
v8::icu::set_common_data_77(deno_core_icudata::ICU_DATA)
.map_err(|error_code| format!("failed to initialize ICU data: {error_code}"))?;
let platform = v8::new_default_platform(0, false).make_shared();
v8::V8::initialize_platform(platform.clone());
v8::V8::initialize();
Ok(platform)
}) {
Ok(_) => Ok(()),
Err(error_text) => Err(error_text.clone()),
}
}
fn run_runtime(
config: RuntimeConfig,
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
+65
View File
@@ -0,0 +1,65 @@
use std::sync::OnceLock;
/// Controls whether V8 may generate executable code at runtime.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum V8JitMode {
#[default]
Enabled,
Disabled,
}
struct V8Initialization {
_platform: v8::SharedRef<v8::Platform>,
jit_mode: V8JitMode,
}
static V8_INITIALIZATION: OnceLock<Result<V8Initialization, String>> = OnceLock::new();
/// Initializes the process-wide V8 platform with the requested JIT mode.
///
/// Call this before executing any code-mode cells when JIT must be disabled.
/// V8 cannot change JIT mode after initialization, so a later call requesting
/// a different mode returns an error. Code mode initializes V8 with JIT enabled
/// by default when this function has not been called explicitly.
pub fn initialize_v8(jit_mode: V8JitMode) -> Result<(), String> {
match V8_INITIALIZATION.get_or_init(|| initialize_v8_with_mode(jit_mode)) {
Ok(initialization) if initialization.jit_mode == jit_mode => Ok(()),
Ok(initialization) => Err(format!(
"V8 was already initialized with JIT {}",
initialization.jit_mode.description()
)),
Err(error_text) => Err(error_text.clone()),
}
}
pub(crate) fn ensure_v8_initialized() -> Result<(), String> {
match V8_INITIALIZATION.get_or_init(|| initialize_v8_with_mode(V8JitMode::Enabled)) {
Ok(_) => Ok(()),
Err(error_text) => Err(error_text.clone()),
}
}
fn initialize_v8_with_mode(jit_mode: V8JitMode) -> Result<V8Initialization, String> {
v8::icu::set_common_data_77(deno_core_icudata::ICU_DATA)
.map_err(|error_code| format!("failed to initialize ICU data: {error_code}"))?;
match jit_mode {
V8JitMode::Enabled => {}
V8JitMode::Disabled => v8::V8::set_flags_from_string("--jitless"),
}
let platform = v8::new_default_platform(0, false).make_shared();
v8::V8::initialize_platform(platform.clone());
v8::V8::initialize();
Ok(V8Initialization {
_platform: platform,
jit_mode,
})
}
impl V8JitMode {
fn description(self) -> &'static str {
match self {
Self::Enabled => "enabled",
Self::Disabled => "disabled",
}
}
}
+41
View File
@@ -0,0 +1,41 @@
use codex_code_mode::ExecuteRequest;
use codex_code_mode::InProcessCodeModeSession;
use codex_code_mode::RuntimeResponse;
use codex_code_mode::V8JitMode;
use codex_code_mode::initialize_v8;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn code_mode_runs_with_jit_disabled() {
initialize_v8(V8JitMode::Disabled).expect("initialize V8 without JIT");
let service = InProcessCodeModeSession::new();
let started = service
.execute(ExecuteRequest {
tool_call_id: "call_1".to_string(),
enabled_tools: Vec::new(),
source: "21 * 2;".to_string(),
yield_time_ms: None,
max_output_tokens: None,
})
.await
.expect("start code-mode cell");
let cell_id = started.cell_id.clone();
let response = started
.initial_response()
.await
.expect("execute code-mode cell");
assert_eq!(
response,
RuntimeResponse::Result {
cell_id,
content_items: Vec::new(),
error_text: None,
}
);
assert_eq!(
initialize_v8(V8JitMode::Enabled),
Err("V8 was already initialized with JIT disabled".to_string())
);
}
+1
View File
@@ -10,6 +10,7 @@ base64 = { workspace = true }
bytes = { workspace = true }
chrono = { workspace = true }
codex-client = { workspace = true }
codex-http-client = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
futures = { workspace = true }
+21
View File
@@ -104,6 +104,11 @@ pub enum ResponseEvent {
delta: String,
summary_index: i64,
},
ReasoningSummaryDone {
item_id: String,
text: String,
summary_index: i64,
},
ReasoningContentDelta {
delta: String,
content_index: i64,
@@ -148,6 +153,17 @@ pub struct Reasoning {
pub context: Option<ReasoningContext>,
}
#[derive(Debug, Serialize, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ReasoningSummaryDelivery {
SequentialCutoff,
}
#[derive(Debug, Serialize, Clone, PartialEq)]
pub struct StreamOptions {
pub reasoning_summary_delivery: ReasoningSummaryDelivery,
}
#[derive(Debug, Serialize, Default, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum TextFormatType {
@@ -209,6 +225,8 @@ pub struct ResponsesApiRequest {
pub reasoning: Option<Reasoning>,
pub store: bool,
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<StreamOptions>,
pub include: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<String>,
@@ -233,6 +251,7 @@ impl From<&ResponsesApiRequest> for ResponseCreateWsRequest {
reasoning: request.reasoning.clone(),
store: request.store,
stream: request.stream,
stream_options: request.stream_options.clone(),
include: request.include.clone(),
service_tier: request.service_tier.clone(),
prompt_cache_key: request.prompt_cache_key.clone(),
@@ -258,6 +277,8 @@ pub struct ResponseCreateWsRequest {
pub reasoning: Option<Reasoning>,
pub store: bool,
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<StreamOptions>,
pub include: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<String>,
@@ -16,7 +16,7 @@ use crate::endpoint::realtime_websocket::protocol::parse_realtime_event;
use crate::error::ApiError;
use crate::provider::Provider;
use codex_client::backoff;
use codex_client::maybe_build_rustls_client_config_with_custom_ca;
use codex_http_client::maybe_build_rustls_client_config_with_custom_ca;
use codex_protocol::protocol::ConversationTextRole;
use codex_protocol::protocol::RealtimeTranscriptDelta;
use codex_utils_rustls_provider::ensure_rustls_crypto_provider;
@@ -391,6 +391,13 @@ impl RealtimeWebsocketWriter {
}
impl RealtimeWebsocketEvents {
pub async fn take_transcript_tail(&self) -> Vec<RealtimeTranscriptEntry> {
let mut active_transcript = self.active_transcript.lock().await;
let tail = active_transcript.entries[active_transcript.last_handoff_entry_count..].to_vec();
active_transcript.last_handoff_entry_count = active_transcript.entries.len();
tail
}
pub async fn next_event(&self) -> Result<Option<RealtimeEvent>, ApiError> {
if self.is_closed.load(Ordering::SeqCst) {
return Ok(None);
@@ -962,6 +969,56 @@ mod tests {
);
}
#[tokio::test]
async fn takes_only_transcript_after_last_handoff_once() {
let (_tx_message, rx_message) = async_channel::unbounded();
let events = RealtimeWebsocketEvents {
rx_message,
active_transcript: Arc::new(Mutex::new(ActiveTranscriptState::default())),
event_parser: RealtimeEventParser::V1,
is_closed: Arc::new(AtomicBool::new(false)),
};
assert_eq!(events.take_transcript_tail().await, vec![]);
let mut covered = RealtimeEvent::InputTranscriptDelta(RealtimeTranscriptDelta {
delta: "already handed off".to_string(),
});
events.update_active_transcript(&mut covered).await;
let mut handoff = RealtimeEvent::HandoffRequested(RealtimeHandoffRequested {
handoff_id: "handoff_1".to_string(),
item_id: "item_1".to_string(),
input_transcript: "already handed off".to_string(),
active_transcript: vec![],
});
events.update_active_transcript(&mut handoff).await;
assert_eq!(
handoff,
RealtimeEvent::HandoffRequested(RealtimeHandoffRequested {
handoff_id: "handoff_1".to_string(),
item_id: "item_1".to_string(),
input_transcript: "already handed off".to_string(),
active_transcript: vec![RealtimeTranscriptEntry {
role: "user".to_string(),
text: "already handed off".to_string(),
}],
})
);
let mut tail = RealtimeEvent::OutputTranscriptDelta(RealtimeTranscriptDelta {
delta: "tail".to_string(),
});
events.update_active_transcript(&mut tail).await;
assert_eq!(
events.take_transcript_tail().await,
vec![RealtimeTranscriptEntry {
role: "assistant".to_string(),
text: "tail".to_string(),
}]
);
assert_eq!(events.take_transcript_tail().await, vec![]);
}
#[test]
fn parse_input_transcript_delta_event() {
let payload = json!({
@@ -11,7 +11,7 @@ use crate::sse::ResponsesStreamEvent;
use crate::sse::process_responses_event;
use crate::telemetry::WebsocketTelemetry;
use codex_client::TransportError;
use codex_client::maybe_build_rustls_client_config_with_custom_ca;
use codex_http_client::maybe_build_rustls_client_config_with_custom_ca;
use codex_utils_rustls_provider::ensure_rustls_crypto_provider;
use futures::SinkExt;
use futures::StreamExt;
@@ -851,6 +851,7 @@ mod tests {
reasoning: None,
store: false,
stream: true,
stream_options: None,
include: vec!["reasoning.encrypted_content".to_string()],
service_tier: Some("priority".to_string()),
prompt_cache_key: Some("cache-key".to_string()),
+1 -1
View File
@@ -2,7 +2,7 @@ use std::time::Duration;
use crate::AuthProvider;
use bytes::Bytes;
use codex_client::build_reqwest_client_with_custom_ca;
use codex_http_client::build_reqwest_client_with_custom_ca;
use futures::Stream;
use reqwest::StatusCode;
use reqwest::header::CONTENT_LENGTH;
+2
View File
@@ -34,11 +34,13 @@ pub use crate::common::RawMemory;
pub use crate::common::RawMemoryMetadata;
pub use crate::common::Reasoning;
pub use crate::common::ReasoningContext;
pub use crate::common::ReasoningSummaryDelivery;
pub use crate::common::ResponseCreateWsRequest;
pub use crate::common::ResponseEvent;
pub use crate::common::ResponseStream;
pub use crate::common::ResponsesApiRequest;
pub use crate::common::ResponsesWsRequest;
pub use crate::common::StreamOptions;
pub use crate::common::TextControls;
pub use crate::common::WS_REQUEST_HEADER_TRACEPARENT_CLIENT_METADATA_KEY;
pub use crate::common::WS_REQUEST_HEADER_TRACESTATE_CLIENT_METADATA_KEY;
+38
View File
@@ -167,6 +167,7 @@ pub struct ResponsesStreamEvent {
item_id: Option<String>,
call_id: Option<String>,
delta: Option<String>,
text: Option<String>,
summary_index: Option<i64>,
content_index: Option<i64>,
safety_buffering: Option<Value>,
@@ -358,6 +359,17 @@ pub fn process_responses_event(
}));
}
}
"response.reasoning_summary_text.done" => {
if let (Some(item_id), Some(text), Some(summary_index)) =
(event.item_id, event.text, event.summary_index)
{
return Ok(Some(ResponseEvent::ReasoningSummaryDone {
item_id,
text,
summary_index,
}));
}
}
"response.reasoning_text.delta" => {
if let (Some(delta), Some(content_index)) = (event.delta, event.content_index) {
return Ok(Some(ResponseEvent::ReasoningContentDelta {
@@ -796,6 +808,32 @@ mod tests {
}
}
#[tokio::test]
async fn parses_reasoning_summary_done() {
let events = run_sse(vec![
json!({
"type": "response.reasoning_summary_text.done",
"item_id": "reasoning-1",
"summary_index": 0,
"text": "Checking",
}),
json!({
"type": "response.completed",
"response": { "id": "resp1" },
}),
])
.await;
assert_matches!(
&events[0],
ResponseEvent::ReasoningSummaryDone {
item_id,
text,
summary_index: 0,
} if item_id == "reasoning-1" && text == "Checking"
);
}
#[tokio::test]
async fn error_when_missing_completed() {
let item1 = json!({
+3
View File
@@ -321,6 +321,7 @@ async fn responses_client_stream_request_preserves_item_ids() -> Result<()> {
reasoning: None,
store: false,
stream: true,
stream_options: None,
include: Vec::new(),
service_tier: None,
prompt_cache_key: None,
@@ -407,6 +408,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> {
reasoning: None,
store: false,
stream: true,
stream_options: None,
include: Vec::new(),
service_tier: None,
prompt_cache_key: None,
@@ -526,6 +528,7 @@ async fn azure_store_sends_ids_and_headers() -> Result<()> {
reasoning: None,
store: true,
stream: true,
stream_options: None,
include: Vec::new(),
service_tier: None,
prompt_cache_key: None,
-1
View File
@@ -2,6 +2,5 @@ load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "codex-client",
compile_data = glob(["tests/fixtures/**"]),
crate_name = "codex_client",
)
+2 -33
View File
@@ -5,47 +5,16 @@ name = "codex-client"
version.workspace = true
[dependencies]
bytes = { workspace = true }
codex-http-client = { workspace = true }
eventsource-stream = { workspace = true }
futures = { workspace = true }
http = { workspace = true }
opentelemetry = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots", "stream"] }
rustls = { workspace = true }
rustls-native-certs = { workspace = true }
rustls-pki-types = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt", "time", "sync"] }
tracing = { workspace = true }
tracing-opentelemetry = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
zstd = { workspace = true }
[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies]
sha2 = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
system-configuration = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.52", features = [
"Win32_Foundation",
"Win32_Networking_WinHttp",
] }
[lints]
workspace = true
[dev-dependencies]
codex-utils-cargo-bin = { workspace = true }
opentelemetry_sdk = { workspace = true }
pretty_assertions = { workspace = true }
rcgen = { workspace = true }
tempfile = { workspace = true }
tracing-subscriber = { workspace = true }
[lib]
doctest = false
test = false
+3 -3
View File
@@ -1,8 +1,8 @@
# codex-client
Generic transport layer that wraps HTTP requests, retries, and streaming primitives without any Codex/OpenAI awareness.
Higher-level request policy layered on `codex-http-client` without any Codex/OpenAI API awareness.
- Defines `HttpTransport` and a default `ReqwestTransport` plus thin `Request`/`Response` types.
- Provides retry utilities (`RetryPolicy`, `RetryOn`, `run_with_retry`, `backoff`) that callers plug into for unary and streaming calls.
- Supplies the `sse_stream` helper to turn byte streams into raw SSE `data:` frames with idle timeouts and surfaced stream errors.
- Consumed by higher-level crates like `codex-api`; it stays neutral on endpoints, headers, or API-specific error shapes.
- Defines the request telemetry callback used by higher-level clients.
- Re-exports the low-level HTTP types temporarily so consumers can migrate to `codex-http-client` incrementally.
+3 -39
View File
@@ -1,49 +1,13 @@
mod chatgpt_cloudflare_cookies;
mod chatgpt_hosts;
mod custom_ca;
mod default_client;
mod error;
mod outbound_proxy;
mod request;
mod retry;
mod sse;
mod telemetry;
mod transport;
pub use crate::chatgpt_cloudflare_cookies::with_chatgpt_cloudflare_cookie_store;
pub use crate::chatgpt_hosts::is_allowed_chatgpt_host;
pub use crate::custom_ca::BuildCustomCaTransportError;
/// Test-only subprocess hook for custom CA coverage.
///
/// This stays public only so the `custom_ca_probe` binary target can reuse the shared helper. It
/// is hidden from normal docs because ordinary callers should use
/// [`build_reqwest_client_with_custom_ca`] instead.
#[doc(hidden)]
pub use crate::custom_ca::build_reqwest_client_for_subprocess_tests;
pub use crate::custom_ca::build_reqwest_client_with_custom_ca;
pub use crate::custom_ca::maybe_build_rustls_client_config_with_custom_ca;
pub use crate::default_client::CodexHttpClient;
pub use crate::default_client::CodexRequestBuilder;
pub use crate::error::StreamError;
pub use crate::error::TransportError;
pub use crate::outbound_proxy::BuildRouteAwareHttpClientError;
pub use crate::outbound_proxy::ClientRouteClass;
pub use crate::outbound_proxy::OutboundProxyConfig;
pub use crate::outbound_proxy::RouteFailureClass;
pub use crate::outbound_proxy::build_reqwest_client_for_route;
pub use crate::request::EncodedJsonBody;
pub use crate::request::PreparedRequestBody;
pub use crate::request::Request;
pub use crate::request::RequestBody;
pub use crate::request::RequestCompression;
pub use crate::request::Response;
pub use crate::retry::RetryOn;
pub use crate::retry::RetryPolicy;
pub use crate::retry::backoff;
pub use crate::retry::run_with_retry;
pub use crate::sse::sse_stream;
pub use crate::telemetry::RequestTelemetry;
pub use crate::transport::ByteStream;
pub use crate::transport::HttpTransport;
pub use crate::transport::ReqwestTransport;
pub use crate::transport::StreamResponse;
pub use codex_http_client::HttpClient as CodexHttpClient;
pub use codex_http_client::RequestBuilder as CodexRequestBuilder;
pub use codex_http_client::*;
+2 -2
View File
@@ -1,5 +1,5 @@
use crate::error::TransportError;
use crate::request::Request;
use codex_http_client::Request;
use codex_http_client::TransportError;
use rand::Rng;
use std::future::Future;
use std::time::Duration;
+2 -2
View File
@@ -1,5 +1,5 @@
use crate::error::StreamError;
use crate::transport::ByteStream;
use codex_http_client::ByteStream;
use codex_http_client::StreamError;
use eventsource_stream::Eventsource;
use futures::StreamExt;
use tokio::sync::mpsc;
+1 -1
View File
@@ -1,4 +1,4 @@
use crate::error::TransportError;
use codex_http_client::TransportError;
use http::StatusCode;
use std::time::Duration;
@@ -142,6 +142,7 @@ impl McpConnectionManager {
tool_plugin_provenance: ToolPluginProvenance,
auth: Option<&CodexAuth>,
elicitation_reviewer: Option<ElicitationReviewerHandle>,
elicitation_lifecycle: Option<crate::ElicitationLifecycle>,
elicitation_router: ElicitationRequestRouter,
) -> Self {
let mut required_servers = mcp_servers
@@ -157,6 +158,7 @@ impl McpConnectionManager {
approval_policy.value(),
initial_permission_profile,
elicitation_reviewer,
elicitation_lifecycle,
elicitation_router,
);
let tool_plugin_provenance = Arc::new(tool_plugin_provenance);
@@ -360,6 +362,7 @@ impl McpConnectionManager {
approval_policy.value(),
permission_profile.clone(),
/*reviewer*/ None,
/*lifecycle*/ None,
ElicitationRequestRouter::default(),
),
startup_cancellation_token: CancellationToken::new(),
@@ -2,6 +2,7 @@ use super::*;
use crate::codex_apps_cache::CodexAppsToolsCache;
use crate::codex_apps_cache::CodexAppsToolsCacheContext;
use crate::declared_openai_file_input_param_names;
use crate::elicitation::ElicitationLifecycle;
use crate::elicitation::ElicitationRequestManager;
use crate::elicitation::ElicitationRequestRouter;
use crate::elicitation::elicitation_is_rejected_by_policy;
@@ -323,6 +324,7 @@ async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() {
AskForApproval::Never,
PermissionProfile::Disabled,
/*reviewer*/ None,
/*lifecycle*/ None,
ElicitationRequestRouter::default(),
);
let (tx_event, _rx_event) = async_channel::bounded(1);
@@ -359,6 +361,7 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel
AskForApproval::Never,
PermissionProfile::Disabled,
/*reviewer*/ None,
/*lifecycle*/ None,
ElicitationRequestRouter::default(),
);
let (tx_event, _rx_event) = async_channel::bounded(1);
@@ -395,17 +398,35 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel
#[tokio::test]
async fn shared_elicitation_router_targets_the_exact_pending_request() {
struct Registration(Arc<AtomicUsize>);
impl Drop for Registration {
fn drop(&mut self) {
self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
}
}
let router = ElicitationRequestRouter::default();
let outstanding = Arc::new(AtomicUsize::new(0));
let lifecycle = ElicitationLifecycle::new({
let outstanding = outstanding.clone();
move || {
outstanding.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Registration(outstanding.clone())
}
});
let manager_a = ElicitationRequestManager::new(
AskForApproval::OnRequest,
PermissionProfile::default(),
/*reviewer*/ None,
Some(lifecycle.clone()),
router.clone(),
);
let manager_b = ElicitationRequestManager::new(
AskForApproval::OnRequest,
PermissionProfile::default(),
/*reviewer*/ None,
Some(lifecycle),
router,
);
let (tx_event, rx_event) = async_channel::bounded(2);
@@ -435,6 +456,7 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() {
else {
panic!("expected elicitation request");
};
assert_eq!(outstanding.load(std::sync::atomic::Ordering::SeqCst), 2);
let (
codex_protocol::mcp::RequestId::String(request_a_id),
codex_protocol::mcp::RequestId::String(request_b_id),
@@ -485,6 +507,7 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() {
.expect("request B response"),
response_b
);
assert_eq!(outstanding.load(std::sync::atomic::Ordering::SeqCst), 0);
}
#[test]
@@ -1539,6 +1562,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
ToolPluginProvenance::default(),
/*auth*/ None,
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
ElicitationRequestRouter::default(),
)
.await;
+33
View File
@@ -52,6 +52,33 @@ pub trait ElicitationReviewer: Send + Sync {
pub type ElicitationReviewerHandle = Arc<dyn ElicitationReviewer>;
/// Holds an owner-provided registration while an MCP elicitation is waiting for a response.
#[derive(Clone)]
pub struct ElicitationLifecycle {
register: Arc<dyn Fn() -> Box<dyn Send + Sync> + Send + Sync>,
}
impl ElicitationLifecycle {
pub fn new<T>(register: impl Fn() -> T + Send + Sync + 'static) -> Self
where
T: Send + Sync + 'static,
{
Self {
register: Arc::new(move || Box::new(register())),
}
}
fn start(&self) -> ActiveElicitation {
ActiveElicitation {
_registration: (self.register)(),
}
}
}
struct ActiveElicitation {
_registration: Box<dyn Send + Sync>,
}
/// Routes model-visible elicitation response tokens to their exact pending responders.
///
/// One router is shared by every MCP runtime created for a thread. The public response token is
@@ -86,6 +113,7 @@ pub(crate) struct ElicitationRequestManager {
pub(crate) permission_profile: Arc<StdMutex<PermissionProfile>>,
auto_deny: Arc<StdMutex<bool>>,
reviewer: Option<ElicitationReviewerHandle>,
lifecycle: Option<ElicitationLifecycle>,
}
impl ElicitationRequestManager {
@@ -93,6 +121,7 @@ impl ElicitationRequestManager {
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
reviewer: Option<ElicitationReviewerHandle>,
lifecycle: Option<ElicitationLifecycle>,
router: ElicitationRequestRouter,
) -> Self {
Self {
@@ -101,6 +130,7 @@ impl ElicitationRequestManager {
permission_profile: Arc::new(StdMutex::new(permission_profile)),
auto_deny: Arc::new(StdMutex::new(false)),
reviewer,
lifecycle,
}
}
@@ -140,6 +170,7 @@ impl ElicitationRequestManager {
let permission_profile = self.permission_profile.clone();
let auto_deny = self.auto_deny.clone();
let reviewer = self.reviewer.clone();
let lifecycle = self.lifecycle.clone();
Box::new(move |id, elicitation| {
let router = router.clone();
let tx_event = tx_event.clone();
@@ -148,6 +179,7 @@ impl ElicitationRequestManager {
let permission_profile = permission_profile.clone();
let auto_deny = auto_deny.clone();
let reviewer = reviewer.clone();
let lifecycle = lifecycle.clone();
async move {
let auto_deny = auto_deny
.lock()
@@ -249,6 +281,7 @@ impl ElicitationRequestManager {
},
};
let (tx, rx) = oneshot::channel();
let _active_elicitation = lifecycle.as_ref().map(ElicitationLifecycle::start);
{
let mut lock = router.requests.lock().await;
lock.insert((server_name.clone(), routed_request_id), tx);
+1
View File
@@ -1,5 +1,6 @@
pub use connection_manager::McpConnectionManager;
pub use connection_manager::tool_is_model_visible;
pub use elicitation::ElicitationLifecycle;
pub use elicitation::ElicitationRequestRouter;
pub use elicitation::ElicitationReviewRequest;
pub use elicitation::ElicitationReviewer;
+2
View File
@@ -339,6 +339,7 @@ pub async fn read_mcp_resource(
tool_plugin_provenance(config),
auth,
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
crate::elicitation::ElicitationRequestRouter::default(),
)
.await;
@@ -416,6 +417,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
tool_plugin_provenance,
auth,
/*elicitation_reviewer*/ None,
/*elicitation_lifecycle*/ None,
crate::elicitation::ElicitationRequestRouter::default(),
)
.await;
@@ -781,6 +781,7 @@ fn remote_installed_plugin_in_marketplace(
name: name.to_string(),
enabled: true,
install_policy: codex_app_server_protocol::PluginInstallPolicy::Available,
install_policy_source: None,
auth_policy: codex_app_server_protocol::PluginAuthPolicy::OnUse,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: None,
+31
View File
@@ -6,6 +6,7 @@ use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginAvailability;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInstallPolicySource;
use codex_app_server_protocol::PluginInterface;
use codex_app_server_protocol::SkillInterface;
use codex_login::CodexAuth;
@@ -152,6 +153,7 @@ pub struct RemoteInstalledPlugin {
pub name: String,
pub enabled: bool,
pub install_policy: PluginInstallPolicy,
pub install_policy_source: Option<PluginInstallPolicySource>,
pub auth_policy: PluginAuthPolicy,
pub availability: PluginAvailability,
pub interface: Option<PluginInterface>,
@@ -169,6 +171,7 @@ pub struct RemotePluginSummary {
pub installed: bool,
pub enabled: bool,
pub install_policy: PluginInstallPolicy,
pub install_policy_source: Option<PluginInstallPolicySource>,
pub auth_policy: PluginAuthPolicy,
pub availability: PluginAvailability,
pub interface: Option<PluginInterface>,
@@ -528,6 +531,26 @@ struct RemoteAppTemplateResponse {
reason: Option<RemoteAppTemplateUnavailableReason>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
enum RemotePluginInstallPolicySource {
#[serde(rename = "WORKSPACE_SETTING")]
WorkspaceSetting,
#[serde(rename = "IMPLICIT_CANONICAL_APP")]
ImplicitCanonicalApp,
#[serde(other)]
Unknown,
}
impl RemotePluginInstallPolicySource {
fn into_protocol(self) -> Option<PluginInstallPolicySource> {
match self {
Self::WorkspaceSetting => Some(PluginInstallPolicySource::WorkspaceSetting),
Self::ImplicitCanonicalApp => Some(PluginInstallPolicySource::ImplicitCanonicalApp),
Self::Unknown => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
struct RemotePluginDirectoryItem {
id: String,
@@ -544,6 +567,7 @@ struct RemotePluginDirectoryItem {
#[serde(default)]
share_principals: Option<Vec<RemotePluginDirectorySharePrincipal>>,
installation_policy: PluginInstallPolicy,
installation_policy_source: Option<RemotePluginInstallPolicySource>,
authentication_policy: PluginAuthPolicy,
#[serde(rename = "status", default)]
availability: PluginAvailability,
@@ -1050,6 +1074,7 @@ pub fn group_remote_installed_plugins_by_marketplaces(
installed: true,
enabled: plugin.enabled,
install_policy: plugin.install_policy,
install_policy_source: plugin.install_policy_source,
auth_policy: plugin.auth_policy,
availability: plugin.availability,
interface: plugin.interface.clone(),
@@ -1483,6 +1508,9 @@ fn build_remote_plugin_summary(
installed: installed_plugin.is_some(),
enabled: installed_plugin.is_some_and(|plugin| plugin.enabled),
install_policy: plugin.installation_policy,
install_policy_source: plugin
.installation_policy_source
.and_then(RemotePluginInstallPolicySource::into_protocol),
auth_policy: plugin.authentication_policy,
availability: plugin.availability,
interface: remote_plugin_interface_to_info(plugin),
@@ -1562,6 +1590,9 @@ fn remote_installed_plugin_to_cache_entry(
name: plugin.name.clone(),
enabled: installed_plugin.enabled,
install_policy: plugin.installation_policy,
install_policy_source: plugin
.installation_policy_source
.and_then(RemotePluginInstallPolicySource::into_protocol),
auth_policy: plugin.authentication_policy,
availability: plugin.availability,
interface: remote_plugin_interface_to_info(plugin),
@@ -648,6 +648,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
install_policy_source: None,
auth_policy: PluginAuthPolicy::OnUse,
availability: PluginAvailability::Available,
interface: Some(expected_plugin_interface()),
@@ -687,6 +688,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
installed: true,
enabled: true,
install_policy: PluginInstallPolicy::Available,
install_policy_source: None,
auth_policy: PluginAuthPolicy::OnUse,
availability: PluginAvailability::Available,
interface: Some(expected_plugin_interface()),

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