Merge remote-tracking branch 'origin/main' into etraut/goal-capacity-retry

This commit is contained in:
Eric Traut
2026-07-08 08:46:46 -07:00
504 changed files with 17701 additions and 4569 deletions
+1 -1
View File
@@ -190,7 +190,7 @@ common:ci-windows-cross --strategy=TestRunner=local
# V8 embeds IsolateData offsets in snapshot builtins; Windows snapshots must be
# generated by a Windows mksnapshot binary rather than the Linux RBE host tool.
common:ci-windows-cross --strategy=V8Mksnapshot=local
common:ci-windows-cross --local_test_jobs=4
common:ci-windows-cross --local_test_jobs=8
common:ci-windows-cross --test_env=RUST_TEST_THREADS=1
# Native Windows CI still covers the PowerShell parser-process tests. The
# cross-built gnullvm binaries currently hang in those tests when run on the
+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
+45
View File
@@ -0,0 +1,45 @@
name: setup-ci
description: Prepare common tools and environment shared by CI jobs.
runs:
using: composite
steps:
# TODO(anp): Move this under CI_BUILD_ROOT once all consumers use the
# shared build-path contract.
- name: Configure Cargo target directory
shell: bash
run: echo "CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-$GITHUB_WORKSPACE/codex-rs/target}" >> "$GITHUB_ENV"
- 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
+1 -1
View File
@@ -48,7 +48,7 @@ runs:
{
echo "files<<EOF"
for binary in ${BINARIES}; do
echo "${GITHUB_WORKSPACE}/codex-rs/target/${TARGET}/release/${binary}.exe"
echo "${CARGO_TARGET_DIR}/${TARGET}/release/${binary}.exe"
done
echo "EOF"
} >> "$GITHUB_OUTPUT"
@@ -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}"
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>
@@ -4,5 +4,7 @@
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>
+12 -6
View File
@@ -258,21 +258,27 @@ def stage_artifacts(
print(staged_checksums)
def upstream_release_pair_paths(source_root: Path, target: str) -> tuple[Path, Path]:
def upstream_release_pair_paths(
target: str,
target_dir: Path,
) -> tuple[Path, Path]:
lib_name = (
"rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a"
)
gn_out = source_root / "target" / target / "release" / "gn_out"
gn_out = target_dir / target / "release" / "gn_out"
return gn_out / "obj" / lib_name, gn_out / "src_binding.rs"
def stage_upstream_release_pair(
source_root: Path,
target: str,
output_dir: Path,
target_dir: Path,
sandbox: bool = False,
) -> None:
lib_path, binding_path = upstream_release_pair_paths(source_root, target)
lib_path, binding_path = upstream_release_pair_paths(
target,
target_dir,
)
stage_artifacts(target, lib_path, binding_path, output_dir, sandbox)
@@ -330,7 +336,7 @@ def parse_args() -> argparse.Namespace:
"stage-upstream-release-pair"
)
stage_upstream_release_pair_parser.add_argument(
"--source-root", type=Path, required=True
"--target-dir", type=Path, required=True
)
stage_upstream_release_pair_parser.add_argument("--target", required=True)
stage_upstream_release_pair_parser.add_argument("--output-dir", required=True)
@@ -373,9 +379,9 @@ def main() -> int:
return 0
if args.command == "stage-upstream-release-pair":
stage_upstream_release_pair(
source_root=args.source_root,
target=args.target,
output_dir=Path(args.output_dir),
target_dir=args.target_dir,
sandbox=args.sandbox,
)
return 0
@@ -0,0 +1,37 @@
import plistlib
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SIGNING_DIR = ROOT / ".github" / "scripts" / "macos-signing"
ALLOW_JIT = "com.apple.security.cs.allow-jit"
ALLOW_UNSIGNED_EXECUTABLE_MEMORY = (
"com.apple.security.cs.allow-unsigned-executable-memory"
)
class MacosSigningEntitlementsTest(unittest.TestCase):
def load(self, binary: str) -> dict[str, bool]:
path = SIGNING_DIR / f"{binary}.entitlements.plist"
with path.open("rb") as file:
return plistlib.load(file)
def test_v8_binaries_allow_unsigned_executable_memory(self) -> None:
expected = {
ALLOW_JIT: True,
ALLOW_UNSIGNED_EXECUTABLE_MEMORY: True,
}
for binary in ["codex", "codex-app-server", "codex-code-mode-host"]:
with self.subTest(binary=binary):
self.assertEqual(self.load(binary), expected)
def test_responses_proxy_keeps_existing_entitlements(self) -> None:
self.assertEqual(
self.load("codex-responses-api-proxy"),
{ALLOW_JIT: True},
)
if __name__ == "__main__":
unittest.main()
+11 -6
View File
@@ -205,8 +205,8 @@ class RustyV8BazelTest(unittest.TestCase):
),
),
rusty_v8_bazel.upstream_release_pair_paths(
Path("/tmp/rusty_v8"),
"x86_64-apple-darwin",
Path("/tmp/rusty_v8/target"),
),
)
self.assertEqual(
@@ -221,25 +221,30 @@ class RustyV8BazelTest(unittest.TestCase):
),
),
rusty_v8_bazel.upstream_release_pair_paths(
Path("/tmp/rusty_v8"),
"x86_64-pc-windows-msvc",
Path("/tmp/rusty_v8/target"),
),
)
def test_stage_upstream_release_pair(self) -> None:
with TemporaryDirectory() as source_dir, TemporaryDirectory() as output_dir:
source_root = Path(source_dir)
with (
TemporaryDirectory() as target_dir,
TemporaryDirectory() as output_dir,
):
gn_out = (
source_root / "target" / "x86_64-pc-windows-msvc" / "release" / "gn_out"
Path(target_dir)
/ "x86_64-pc-windows-msvc"
/ "release"
/ "gn_out"
)
(gn_out / "obj").mkdir(parents=True)
(gn_out / "obj" / "rusty_v8.lib").write_bytes(b"archive")
(gn_out / "src_binding.rs").write_text("binding")
rusty_v8_bazel.stage_upstream_release_pair(
source_root,
"x86_64-pc-windows-msvc",
Path(output_dir),
Path(target_dir),
sandbox=True,
)
@@ -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 }}
@@ -212,7 +206,7 @@ jobs:
--profile ${{ inputs.profile }} \
-p codex-linux-sandbox \
--bin codex-linux-sandbox
cp "target/${{ inputs.target }}/${{ inputs.profile }}/codex-linux-sandbox" "${helper_dir}/"
cp "${CARGO_TARGET_DIR}/${{ inputs.target }}/${{ inputs.profile }}/codex-linux-sandbox" "${helper_dir}/"
else
cargo build \
--target ${{ inputs.target }} \
@@ -220,8 +214,8 @@ jobs:
-p codex-windows-sandbox \
--bin codex-windows-sandbox-setup \
--bin codex-command-runner
cp "target/${{ inputs.target }}/${{ inputs.profile }}/codex-windows-sandbox-setup.exe" "${helper_dir}/"
cp "target/${{ inputs.target }}/${{ inputs.profile }}/codex-command-runner.exe" "${helper_dir}/"
cp "${CARGO_TARGET_DIR}/${{ inputs.target }}/${{ inputs.profile }}/codex-windows-sandbox-setup.exe" "${helper_dir}/"
cp "${CARGO_TARGET_DIR}/${{ inputs.target }}/${{ inputs.profile }}/codex-command-runner.exe" "${helper_dir}/"
fi
- name: Upload Cargo timings (nextest)
@@ -229,7 +223,7 @@ jobs:
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: cargo-timings-rust-ci-nextest-${{ inputs.target }}-${{ inputs.profile }}
path: codex-rs/target/**/cargo-timings/cargo-timing.html
path: ${{ env.CARGO_TARGET_DIR }}/**/cargo-timings/cargo-timing.html
if-no-files-found: warn
- name: Upload nextest archive
@@ -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 }}
@@ -376,13 +369,13 @@ jobs:
if [[ "${RUNNER_OS}" == "Linux" ]]; then
helper_dir="${RUNNER_TEMP}/${TEST_HELPERS_ARTIFACT}"
helper_target_dir="$(pwd)/target/${{ inputs.target }}/${{ inputs.profile }}"
helper_target_dir="${CARGO_TARGET_DIR}/${{ inputs.target }}/${{ inputs.profile }}"
mkdir -p "${helper_target_dir}"
cp "${helper_dir}/codex-linux-sandbox" "${helper_target_dir}/"
chmod +x "${helper_target_dir}/codex-linux-sandbox"
elif [[ "${RUNNER_OS}" == "Windows" ]]; then
helper_dir="${RUNNER_TEMP}/${TEST_HELPERS_ARTIFACT}"
helper_target_dir="$(pwd)/target/${{ inputs.target }}/${{ inputs.profile }}"
helper_target_dir="${CARGO_TARGET_DIR}/${{ inputs.target }}/${{ inputs.profile }}"
mkdir -p "${helper_target_dir}"
cp "${helper_dir}/codex-windows-sandbox-setup.exe" "${helper_target_dir}/"
cp "${helper_dir}/codex-command-runner.exe" "${helper_target_dir}/"
@@ -430,7 +423,7 @@ jobs:
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: nextest-junit-rust-ci-${{ inputs.artifact_id }}-shard-${{ matrix.shard }}
path: codex-rs/target/nextest/default/junit.xml
path: ${{ env.CARGO_TARGET_DIR }}/nextest/default/junit.xml
if-no-files-found: warn
- name: Tear down remote test env
+5 -12
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
@@ -410,7 +403,7 @@ jobs:
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: cargo-timings-rust-ci-clippy-${{ matrix.target }}-${{ matrix.profile }}
path: codex-rs/target/**/cargo-timings/cargo-timing.html
path: ${{ env.CARGO_TARGET_DIR }}/**/cargo-timings/cargo-timing.html
if-no-files-found: warn
# Save caches explicitly; make non-fatal so cache packaging
+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
+20 -22
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: |
@@ -112,13 +110,13 @@ jobs:
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: cargo-timings-rust-release-windows-${{ matrix.target }}-${{ matrix.bundle }}
path: codex-rs/target/**/cargo-timings/cargo-timing.html
path: ${{ env.CARGO_TARGET_DIR }}/**/cargo-timings/cargo-timing.html
if-no-files-found: warn
- name: Stage Windows binaries
shell: bash
run: |
release_dir="target/${{ matrix.target }}/release"
release_dir="$CARGO_TARGET_DIR/${{ matrix.target }}/release"
output_dir="$release_dir/staged-${{ matrix.bundle }}"
mkdir -p "$output_dir"
for binary in ${{ matrix.binaries }}; do
@@ -141,7 +139,7 @@ jobs:
with:
name: windows-binaries-${{ matrix.target }}-${{ matrix.bundle }}
path: |
codex-rs/target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}/*
${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release/staged-${{ matrix.bundle }}/*
build-windows-symbols:
needs:
@@ -164,19 +162,20 @@ 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:
pattern: windows-binaries-${{ matrix.target }}-*
merge-multiple: true
path: codex-rs/target/${{ matrix.target }}/release
path: ${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release
- name: Build symbols archive
shell: bash
run: |
bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \
--target "${{ matrix.target }}" \
--artifact-name "${{ matrix.target }}" \
--release-dir "target/${{ matrix.target }}/release" \
--release-dir "${CARGO_TARGET_DIR}/${{ matrix.target }}/release" \
--archive-dir "symbols-dist/${{ matrix.target }}" \
--binaries "${WINDOWS_BINARIES}"
- name: Upload symbols archive
@@ -221,30 +220,32 @@ 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:
name: windows-binaries-${{ matrix.target }}-primary
path: codex-rs/target/${{ matrix.target }}/release
path: ${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release
- name: Download prebuilt Windows helper binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: windows-binaries-${{ matrix.target }}-helpers
path: codex-rs/target/${{ matrix.target }}/release
path: ${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release
- name: Download prebuilt Windows app-server binary
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: windows-binaries-${{ matrix.target }}-app-server
path: codex-rs/target/${{ matrix.target }}/release
path: ${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release
- name: Verify binaries
shell: bash
run: |
set -euo pipefail
for binary in ${WINDOWS_BINARIES}; do
ls -lh "target/${{ matrix.target }}/release/${binary}.exe"
ls -lh "$CARGO_TARGET_DIR/${{ matrix.target }}/release/${binary}.exe"
done
- name: Sign Windows binaries with Azure Trusted Signing
@@ -266,13 +267,10 @@ jobs:
mkdir -p "$dest"
for binary in ${WINDOWS_BINARIES}; do
cp "target/${{ matrix.target }}/release/${binary}.exe" \
cp "$CARGO_TARGET_DIR/${{ matrix.target }}/release/${binary}.exe" \
"$dest/${binary}-${{ matrix.target }}.exe"
done
- name: Install DotSlash
uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
- name: Build Codex package archives
shell: bash
run: |
@@ -293,7 +291,7 @@ jobs:
bash "$archive_script" \
--target "$target" \
--bundle "{}" \
--entrypoint-dir "target/$target/release" \
--entrypoint-dir "$CARGO_TARGET_DIR/$target/release" \
--archive-dir "dist/$target"
- name: Build Python runtime wheel
+71 -30
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
@@ -564,6 +573,12 @@ jobs:
zstd -d --stdout "$unsigned_path" >"$signed_path"
chmod 0755 "$signed_path"
entitlements="${GITHUB_WORKSPACE}/.github/scripts/macos-signing/${binary}.entitlements.plist"
if [[ ! -f "$entitlements" ]]; then
echo "Entitlements file $entitlements not found"
exit 1
fi
.github/scripts/macos-signing/sign_macos_code.sh \
--target "$signed_path" \
--identity unused \
@@ -571,7 +586,7 @@ jobs:
--identifier "$binary" \
--options runtime \
--timestamp true \
--entitlements .github/scripts/macos-signing/codex.entitlements.plist
--entitlements "$entitlements"
mkdir -p "${report_dir}/${binary}"
rcodesign print-signature-info "$signed_path" \
@@ -615,22 +630,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 +722,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 +931,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:
@@ -960,15 +980,29 @@ jobs:
target="${{ matrix.target }}"
packaged_dir="dist/${target}"
expected_entitlements="${GITHUB_WORKSPACE}/.github/scripts/macos-signing/codex.entitlements.plist"
case "$target" in
aarch64-apple-darwin) expected_arch="arm64" ;;
x86_64-apple-darwin) expected_arch="x86_64" ;;
*)
echo "Unexpected macOS target: $target"
exit 1
;;
esac
verify_signed_binary() {
local path="$1"
local actual_entitlements normalized_actual normalized_expected
local binary="$2"
local actual_entitlements expected_entitlements normalized_actual normalized_expected
chmod 0755 "$path"
lipo "$path" -verify_arch "$expected_arch"
codesign --verify --strict --verbose=2 "$path"
expected_entitlements="${GITHUB_WORKSPACE}/.github/scripts/macos-signing/${binary}.entitlements.plist"
if [[ ! -f "$expected_entitlements" ]]; then
echo "Expected entitlements file $expected_entitlements not found"
exit 1
fi
actual_entitlements="$(mktemp)"
normalized_actual="$(mktemp)"
normalized_expected="$(mktemp)"
@@ -981,17 +1015,23 @@ jobs:
for binary in ${{ matrix.binaries }}; do
binary_path="${RUNNER_TEMP}/signed-binaries/${binary}"
verify_signed_binary "$binary_path"
verify_signed_binary "$binary_path" "$binary"
# 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"
tar -xzf "${packaged_dir}/${binary}-${target}.tar.gz" -C "$direct_archive_dir"
verify_signed_binary "${direct_archive_dir}/${binary}-${target}"
verify_signed_binary "${direct_archive_dir}/${binary}-${target}" "$binary"
direct_zstd_path="${RUNNER_TEMP}/${binary}-${target}-from-zstd"
zstd -d --stdout "${packaged_dir}/${binary}-${target}.zst" >"$direct_zstd_path"
verify_signed_binary "$direct_zstd_path"
verify_signed_binary "$direct_zstd_path" "$binary"
done
case "${{ matrix.bundle }}" in
@@ -1013,7 +1053,8 @@ jobs:
rm -rf "$package_dir"
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/${package_entrypoint}" "$package_entrypoint"
verify_signed_binary "${package_dir}/bin/codex-code-mode-host" "codex-code-mode-host"
if [[ "${{ matrix.verify_dmg }}" != "true" ]]; then
exit 0
@@ -1039,7 +1080,7 @@ jobs:
trap cleanup_mount EXIT
for binary in ${{ matrix.binaries }}; do
verify_signed_binary "${mount_dir}/${binary}"
verify_signed_binary "${mount_dir}/${binary}" "$binary"
done
cleanup_mount
+1 -1
View File
@@ -392,7 +392,7 @@ jobs:
run: |
set -euo pipefail
python3 .github/scripts/rusty_v8_bazel.py stage-upstream-release-pair \
--source-root upstream-rusty-v8 \
--target-dir upstream-rusty-v8/target \
--target "${TARGET}" \
--output-dir "dist/${TARGET}" \
--sandbox
+12 -13
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' }}
@@ -313,18 +308,22 @@ jobs:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/upstream-rusty-v8/target
strategy:
fail-fast: false
matrix:
include:
- runner: windows-2022
- runner: windows-2025
target: x86_64-pc-windows-msvc
- runner: windows-2022
- runner: windows-2025
target: aarch64-pc-windows-msvc
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
@@ -367,17 +366,17 @@ jobs:
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: |
upstream-rusty-v8/target/sccache
upstream-rusty-v8/target/${{ matrix.target }}/release/gn_out
key: rusty-v8-source-${{ matrix.target }}-sandbox-${{ hashFiles('upstream-rusty-v8/Cargo.lock', 'upstream-rusty-v8/build.rs', 'upstream-rusty-v8/git_submodule_status.txt') }}
${{ env.CARGO_TARGET_DIR }}/sccache
${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release/gn_out
key: rusty-v8-source-${{ matrix.target }}-${{ matrix.runner }}-sandbox-${{ hashFiles('upstream-rusty-v8/Cargo.lock', 'upstream-rusty-v8/build.rs', 'upstream-rusty-v8/git_submodule_status.txt') }}
restore-keys: |
rusty-v8-source-${{ matrix.target }}-sandbox-
rusty-v8-source-${{ matrix.target }}-${{ matrix.runner }}-sandbox-
- name: Install and start sccache
shell: pwsh
env:
SCCACHE_CACHE_SIZE: 256M
SCCACHE_DIR: ${{ github.workspace }}/upstream-rusty-v8/target/sccache
SCCACHE_DIR: ${{ env.CARGO_TARGET_DIR }}/sccache
SCCACHE_IDLE_TIMEOUT: 0
run: |
$version = "v0.8.2"
@@ -412,7 +411,7 @@ jobs:
run: |
set -euo pipefail
python3 .github/scripts/rusty_v8_bazel.py stage-upstream-release-pair \
--source-root upstream-rusty-v8 \
--target-dir "${CARGO_TARGET_DIR}" \
--target "${TARGET}" \
--output-dir "dist/${TARGET}" \
--sandbox
+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\":{}}",
+61 -29
View File
@@ -412,6 +412,7 @@ dependencies = [
"serde",
"serde_json",
"shlex",
"tempfile",
"tokio",
"uuid",
"wiremock",
@@ -1936,6 +1937,7 @@ dependencies = [
"bytes",
"chrono",
"codex-client",
"codex-http-client",
"codex-protocol",
"codex-utils-rustls-provider",
"eventsource-stream",
@@ -2106,6 +2108,7 @@ dependencies = [
"anyhow",
"clap",
"codex-experimental-api-macros",
"codex-extension-items",
"codex-protocol",
"codex-shell-command",
"codex-utils-absolute-path",
@@ -2262,7 +2265,7 @@ dependencies = [
"anyhow",
"codex-api",
"codex-backend-openapi-models",
"codex-client",
"codex-http-client",
"codex-login",
"codex-model-provider",
"codex-protocol",
@@ -2392,33 +2395,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 +2434,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 +2508,7 @@ dependencies = [
"codex-utils-cargo-bin",
"pretty_assertions",
"serde_json",
"tempfile",
"tokio",
"tokio-util",
]
@@ -2660,12 +2643,14 @@ dependencies = [
"codex-exec-server",
"codex-execpolicy",
"codex-extension-api",
"codex-extension-items",
"codex-features",
"codex-feedback",
"codex-file-system",
"codex-git-utils",
"codex-home",
"codex-hooks",
"codex-http-client",
"codex-image-generation-extension",
"codex-install-context",
"codex-login",
@@ -2915,9 +2900,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",
@@ -3031,6 +3016,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "codex-extension-items"
version = "0.0.0"
dependencies = [
"codex-utils-absolute-path",
"pretty_assertions",
"schemars 0.8.22",
"serde",
"serde_json",
"ts-rs",
]
[[package]]
name = "codex-external-agent-migration"
version = "0.0.0"
@@ -3213,6 +3210,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"
@@ -3222,6 +3250,7 @@ dependencies = [
"codex-core",
"codex-exec-server",
"codex-extension-api",
"codex-extension-items",
"codex-login",
"codex-model-provider",
"codex-model-provider-info",
@@ -3264,6 +3293,7 @@ dependencies = [
"clap",
"codex-core",
"codex-install-context",
"codex-network-proxy",
"codex-process-hardening",
"codex-protocol",
"codex-sandboxing",
@@ -3303,8 +3333,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 +3548,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",
@@ -3700,6 +3730,7 @@ dependencies = [
"chrono",
"codex-async-utils",
"codex-execpolicy",
"codex-extension-items",
"codex-network-proxy",
"codex-utils-absolute-path",
"codex-utils-image",
@@ -4057,6 +4088,7 @@ version = "0.0.0"
dependencies = [
"codex-code-mode",
"codex-connectors",
"codex-extension-items",
"codex-features",
"codex-file-system",
"codex-protocol",
@@ -4821,9 +4853,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",
]
+4
View File
@@ -41,6 +41,7 @@ members = [
"core-plugins",
"core-skills",
"hooks",
"http-client",
"secrets",
"exec",
"file-system",
@@ -53,6 +54,7 @@ members = [
"ext/goal",
"ext/guardian",
"ext/image-generation",
"ext/items",
"ext/memories",
"ext/mcp",
"ext/skills",
@@ -164,6 +166,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" }
@@ -178,6 +181,7 @@ codex-exec-server-protocol = { path = "exec-server-protocol" }
codex-exec-server = { path = "exec-server" }
codex-execpolicy = { path = "execpolicy" }
codex-extension-api = { path = "ext/extension-api" }
codex-extension-items = { path = "ext/items" }
codex-goal-extension = { path = "ext/goal" }
codex-guardian = { path = "ext/guardian" }
codex-image-generation-extension = { path = "ext/image-generation" }
@@ -103,6 +103,7 @@ use codex_app_server_protocol::GuardianApprovalReview;
use codex_app_server_protocol::GuardianApprovalReviewAction;
use codex_app_server_protocol::GuardianApprovalReviewStatus;
use codex_app_server_protocol::GuardianCommandSource as AppServerGuardianCommandSource;
use codex_app_server_protocol::ImageGenerationItem;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::ItemCompletedNotification;
@@ -4405,13 +4406,13 @@ async fn turn_event_counts_completed_tool_items() {
query: "codex".to_string(),
action: None,
},
ThreadItem::ImageGeneration {
ThreadItem::ImageGeneration(ImageGenerationItem {
id: "image-1".to_string(),
status: "completed".to_string(),
revised_prompt: None,
result: "ok".to_string(),
saved_path: None,
},
}),
];
for item in completed_tool_items {
+8 -14
View File
@@ -403,7 +403,7 @@ impl TurnToolCounts {
self.subagent_tool_call += 1;
}
ThreadItem::WebSearch { .. } => self.web_search += 1,
ThreadItem::ImageGeneration { .. } => self.image_generation += 1,
ThreadItem::ImageGeneration(_) => self.image_generation += 1,
ThreadItem::UserMessage { .. }
| ThreadItem::HookPrompt { .. }
| ThreadItem::AgentMessage { .. }
@@ -1733,8 +1733,8 @@ fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> {
| ThreadItem::McpToolCall { id, .. }
| ThreadItem::DynamicToolCall { id, .. }
| ThreadItem::CollabAgentToolCall { id, .. }
| ThreadItem::WebSearch { id, .. }
| ThreadItem::ImageGeneration { id, .. } => Some(id),
| ThreadItem::WebSearch { id, .. } => Some(id),
ThreadItem::ImageGeneration(item) => Some(&item.id),
ThreadItem::UserMessage { .. }
| ThreadItem::HookPrompt { .. }
| ThreadItem::AgentMessage { .. }
@@ -2057,18 +2057,12 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
},
}))
}
ThreadItem::ImageGeneration {
id,
status,
revised_prompt,
saved_path,
..
} => {
let (terminal_status, failure_kind) = image_generation_outcome(status.as_str());
ThreadItem::ImageGeneration(item) => {
let (terminal_status, failure_kind) = image_generation_outcome(item.status.as_str());
let base = tool_item_base(
thread_id,
turn_id,
id.clone(),
item.id.clone(),
"image_generation".to_string(),
ToolItemOutcome {
terminal_status,
@@ -2089,8 +2083,8 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
event_type: "codex_image_generation_event",
event_params: CodexImageGenerationEventParams {
base,
revised_prompt_present: revised_prompt.is_some(),
saved_path_present: saved_path.is_some(),
revised_prompt_present: item.revised_prompt.is_some(),
saved_path_present: item.saved_path.is_some(),
},
},
))
-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::*;
+1
View File
@@ -16,6 +16,7 @@ workspace = true
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-experimental-api-macros = { workspace = true }
codex-extension-items = { workspace = true }
codex-protocol = { workspace = true }
codex-shell-command = { workspace = true }
codex-utils-absolute-path = { workspace = true }
@@ -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"
@@ -1456,6 +1463,17 @@
},
{
"properties": {
"appBrand": {
"anyOf": [
{
"$ref": "#/definitions/LoginAppBrand"
},
{
"type": "null"
}
],
"default": null
},
"codexStreamlinedLogin": {
"type": "boolean"
},
@@ -1465,6 +1483,9 @@
],
"title": "ChatgptLoginAccountParamsType",
"type": "string"
},
"useHostedLoginSuccessPage": {
"type": "boolean"
}
},
"required": [
@@ -1525,6 +1546,13 @@
}
]
},
"LoginAppBrand": {
"enum": [
"codex",
"chatgpt"
],
"type": "string"
},
"MarketplaceAddParams": {
"properties": {
"refName": {
@@ -536,6 +536,13 @@
],
"type": "string"
},
{
"description": "Backend auth supplied as request headers.",
"enum": [
"headers"
],
"type": "string"
},
{
"description": "Programmatic Codex auth backed by a registered Agent Identity.",
"enum": [
@@ -6728,6 +6728,7 @@
"enum": [
"auto",
"prompt",
"writes",
"approve"
],
"type": "string"
@@ -6950,6 +6951,13 @@
],
"type": "string"
},
{
"description": "Backend auth supplied as request headers.",
"enum": [
"headers"
],
"type": "string"
},
{
"description": "Programmatic Codex auth backed by a registered Agent Identity.",
"enum": [
@@ -8688,6 +8696,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"
@@ -11496,6 +11511,17 @@
},
{
"properties": {
"appBrand": {
"anyOf": [
{
"$ref": "#/definitions/v2/LoginAppBrand"
},
{
"type": "null"
}
],
"default": null
},
"codexStreamlinedLogin": {
"type": "boolean"
},
@@ -11505,6 +11531,9 @@
],
"title": "Chatgptv2::LoginAccountParamsType",
"type": "string"
},
"useHostedLoginSuccessPage": {
"type": "boolean"
}
},
"required": [
@@ -11659,6 +11688,13 @@
],
"title": "LoginAccountResponse"
},
"LoginAppBrand": {
"enum": [
"codex",
"chatgpt"
],
"type": "string"
},
"LogoutAccountResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "LogoutAccountResponse",
@@ -13416,6 +13452,13 @@
],
"type": "string"
},
"PluginInstallPolicySource": {
"enum": [
"WORKSPACE_SETTING",
"IMPLICIT_CANONICAL_APP"
],
"type": "string"
},
"PluginInstallResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -14299,6 +14342,16 @@
"installPolicy": {
"$ref": "#/definitions/v2/PluginInstallPolicy"
},
"installPolicySource": {
"anyOf": [
{
"$ref": "#/definitions/v2/PluginInstallPolicySource"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},
@@ -14533,11 +14586,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 +14665,13 @@
],
"type": "object"
},
"RateLimitResetType": {
"enum": [
"codexRateLimits",
"unknown"
],
"type": "string"
},
"RateLimitSnapshot": {
"properties": {
"credits": {
@@ -854,6 +854,7 @@
"enum": [
"auto",
"prompt",
"writes",
"approve"
],
"type": "string"
@@ -1076,6 +1077,13 @@
],
"type": "string"
},
{
"description": "Backend auth supplied as request headers.",
"enum": [
"headers"
],
"type": "string"
},
{
"description": "Programmatic Codex auth backed by a registered Agent Identity.",
"enum": [
@@ -4928,6 +4936,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"
@@ -7900,6 +7915,17 @@
},
{
"properties": {
"appBrand": {
"anyOf": [
{
"$ref": "#/definitions/LoginAppBrand"
},
{
"type": "null"
}
],
"default": null
},
"codexStreamlinedLogin": {
"type": "boolean"
},
@@ -7909,6 +7935,9 @@
],
"title": "Chatgptv2::LoginAccountParamsType",
"type": "string"
},
"useHostedLoginSuccessPage": {
"type": "boolean"
}
},
"required": [
@@ -8063,6 +8092,13 @@
],
"title": "LoginAccountResponse"
},
"LoginAppBrand": {
"enum": [
"codex",
"chatgpt"
],
"type": "string"
},
"LogoutAccountResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "LogoutAccountResponse",
@@ -9820,6 +9856,13 @@
],
"type": "string"
},
"PluginInstallPolicySource": {
"enum": [
"WORKSPACE_SETTING",
"IMPLICIT_CANONICAL_APP"
],
"type": "string"
},
"PluginInstallResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -10703,6 +10746,16 @@
"installPolicy": {
"$ref": "#/definitions/PluginInstallPolicy"
},
"installPolicySource": {
"anyOf": [
{
"$ref": "#/definitions/PluginInstallPolicySource"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},
@@ -10937,11 +10990,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 +11069,13 @@
],
"type": "object"
},
"RateLimitResetType": {
"enum": [
"codexRateLimits",
"unknown"
],
"type": "string"
},
"RateLimitSnapshot": {
"properties": {
"credits": {
@@ -25,6 +25,13 @@
],
"type": "string"
},
{
"description": "Backend auth supplied as request headers.",
"enum": [
"headers"
],
"type": "string"
},
{
"description": "Programmatic Codex auth backed by a registered Agent Identity.",
"enum": [
@@ -78,6 +78,7 @@
"enum": [
"auto",
"prompt",
"writes",
"approve"
],
"type": "string"
@@ -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": {
@@ -1,5 +1,14 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"LoginAppBrand": {
"enum": [
"codex",
"chatgpt"
],
"type": "string"
}
},
"oneOf": [
{
"properties": {
@@ -23,6 +32,17 @@
},
{
"properties": {
"appBrand": {
"anyOf": [
{
"$ref": "#/definitions/LoginAppBrand"
},
{
"type": "null"
}
],
"default": null
},
"codexStreamlinedLogin": {
"type": "boolean"
},
@@ -32,6 +52,9 @@
],
"title": "Chatgptv2::LoginAccountParamsType",
"type": "string"
},
"useHostedLoginSuccessPage": {
"type": "boolean"
}
},
"required": [
@@ -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"
},
+1 -1
View File
@@ -5,4 +5,4 @@
/**
* Authentication mode for OpenAI-backed providers.
*/
export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity" | "personalAccessToken" | "bedrockApiKey";
export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "headers" | "agentIdentity" | "personalAccessToken" | "bedrockApiKey";
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "./AbsolutePathBuf";
export type ImageGenerationItem = { id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, };
+1
View File
@@ -37,6 +37,7 @@ export type { GitDiffToRemoteParams } from "./GitDiffToRemoteParams";
export type { GitDiffToRemoteResponse } from "./GitDiffToRemoteResponse";
export type { GitSha } from "./GitSha";
export type { ImageDetail } from "./ImageDetail";
export type { ImageGenerationItem } from "./ImageGenerationItem";
export type { InitializeCapabilities } from "./InitializeCapabilities";
export type { InitializeParams } from "./InitializeParams";
export type { InitializeResponse } from "./InitializeResponse";
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AppToolApproval = "auto" | "prompt" | "approve";
export type AppToolApproval = "auto" | "prompt" | "writes" | "approve";
@@ -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, };
@@ -1,8 +1,9 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { LoginAppBrand } from "./LoginAppBrand";
export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt", codexStreamlinedLogin?: boolean, } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens",
export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt", codexStreamlinedLogin?: boolean, useHostedLoginSuccessPage?: boolean, appBrand?: LoginAppBrand | null, } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens",
/**
* Access token (JWT) supplied by the client.
* This token is used for backend API requests and email extraction.
@@ -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 LoginAppBrand = "codex" | "chatgpt";
@@ -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";
@@ -1,7 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { ImageGenerationItem } from "../ImageGenerationItem";
import type { LegacyAppPathString } from "../LegacyAppPathString";
import type { MessagePhase } from "../MessagePhase";
import type { ReasoningEffort } from "../ReasoningEffort";
@@ -105,4 +105,4 @@ reasoningEffort: ReasoningEffort | null,
/**
* Last known status of the target agents, when available.
*/
agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, };
agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration" } & ImageGenerationItem | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, };
@@ -194,6 +194,7 @@ export type { ListMcpServerStatusParams } from "./ListMcpServerStatusParams";
export type { ListMcpServerStatusResponse } from "./ListMcpServerStatusResponse";
export type { LoginAccountParams } from "./LoginAccountParams";
export type { LoginAccountResponse } from "./LoginAccountResponse";
export type { LoginAppBrand } from "./LoginAppBrand";
export type { LogoutAccountResponse } from "./LogoutAccountResponse";
export type { ManagedHooksRequirements } from "./ManagedHooksRequirements";
export type { MarketplaceAddParams } from "./MarketplaceAddParams";
@@ -294,6 +295,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 +337,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";
@@ -31,6 +31,11 @@ pub enum AuthMode {
#[ts(rename = "chatgptAuthTokens")]
#[strum(serialize = "chatgptAuthTokens")]
ChatgptAuthTokens,
/// Backend auth supplied as request headers.
#[serde(rename = "headers")]
#[ts(rename = "headers")]
#[strum(serialize = "headers")]
Headers,
/// Programmatic Codex auth backed by a registered Agent Identity.
#[serde(rename = "agentIdentity")]
#[ts(rename = "agentIdentity")]
@@ -53,7 +58,7 @@ impl AuthMode {
pub fn has_chatgpt_account(self) -> bool {
match self {
Self::Chatgpt | Self::ChatgptAuthTokens | Self::PersonalAccessToken => true,
Self::ApiKey | Self::AgentIdentity | Self::BedrockApiKey => false,
Self::ApiKey | Self::Headers | Self::AgentIdentity | Self::BedrockApiKey => false,
}
}
@@ -62,6 +67,7 @@ impl AuthMode {
match self {
Self::Chatgpt
| Self::ChatgptAuthTokens
| Self::Headers
| Self::AgentIdentity
| Self::PersonalAccessToken => true,
Self::ApiKey | Self::BedrockApiKey => false,
@@ -2721,7 +2727,9 @@ mod tests {
let request = ClientRequest::LoginAccount {
request_id: RequestId::Integer(3),
params: v2::LoginAccountParams::Chatgpt {
app_brand: None,
codex_streamlined_login: false,
use_hosted_login_success_page: false,
},
};
assert_eq!(
@@ -2729,7 +2737,8 @@ mod tests {
"method": "account/login/start",
"id": 3,
"params": {
"type": "chatgpt"
"type": "chatgpt",
"appBrand": null
}
}),
serde_json::to_value(&request)?,
@@ -2742,7 +2751,9 @@ mod tests {
let request = ClientRequest::LoginAccount {
request_id: RequestId::Integer(3),
params: v2::LoginAccountParams::Chatgpt {
app_brand: None,
codex_streamlined_login: true,
use_hosted_login_success_page: false,
},
};
assert_eq!(
@@ -2751,6 +2762,7 @@ mod tests {
"id": 3,
"params": {
"type": "chatgpt",
"appBrand": null,
"codexStreamlinedLogin": true
}
}),
@@ -2759,6 +2771,32 @@ mod tests {
Ok(())
}
#[test]
fn serialize_account_login_chatgpt_with_hosted_success_page() -> Result<()> {
let request = ClientRequest::LoginAccount {
request_id: RequestId::Integer(3),
params: v2::LoginAccountParams::Chatgpt {
app_brand: Some(v2::LoginAppBrand::Chatgpt),
codex_streamlined_login: true,
use_hosted_login_success_page: true,
},
};
assert_eq!(
json!({
"method": "account/login/start",
"id": 3,
"params": {
"type": "chatgpt",
"appBrand": "chatgpt",
"codexStreamlinedLogin": true,
"useHostedLoginSuccessPage": true
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn serialize_account_login_chatgpt_device_code() -> Result<()> {
let request = ClientRequest::LoginAccount {
@@ -3195,6 +3233,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 +3255,7 @@ mod tests {
"params": {
"threadId": "thr_123",
"clientManagedHandoffs": true,
"flushTranscriptTailOnSessionEnd": true,
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffPrefix": "silent context",
@@ -3240,6 +3280,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 +3302,7 @@ mod tests {
"params": {
"threadId": "thr_123",
"clientManagedHandoffs": null,
"flushTranscriptTailOnSessionEnd": null,
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffPrefix": null,
@@ -3280,6 +3322,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 +3344,7 @@ mod tests {
"params": {
"threadId": "thr_123",
"clientManagedHandoffs": null,
"flushTranscriptTailOnSessionEnd": null,
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffPrefix": null,
@@ -3518,6 +3562,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,
@@ -22,6 +22,7 @@ use crate::protocol::v2::TurnItemsView;
use crate::protocol::v2::TurnStatus;
use crate::protocol::v2::UserInput;
use crate::protocol::v2::WebSearchAction;
use codex_extension_items::image_generation::ImageGenerationItem;
use codex_protocol::items::parse_hook_prompt_message;
use codex_protocol::models::MessagePhase;
use codex_protocol::protocol::AgentReasoningEvent;
@@ -592,7 +593,8 @@ impl ThreadHistoryBuilder {
| codex_protocol::items::TurnItem::CommandExecution(_)
| codex_protocol::items::TurnItem::DynamicToolCall(_)
| codex_protocol::items::TurnItem::CollabAgentToolCall(_)
| codex_protocol::items::TurnItem::SubAgentActivity(_) => true,
| codex_protocol::items::TurnItem::SubAgentActivity(_)
| codex_protocol::items::TurnItem::Extension(_) => true,
codex_protocol::items::TurnItem::UserMessage(_)
| codex_protocol::items::TurnItem::HookPrompt(_)
| codex_protocol::items::TurnItem::AgentMessage(_)
@@ -827,24 +829,24 @@ impl ThreadHistoryBuilder {
}
fn handle_image_generation_begin(&mut self, payload: &ImageGenerationBeginEvent) {
let item = ThreadItem::ImageGeneration {
let item = ThreadItem::ImageGeneration(ImageGenerationItem {
id: payload.call_id.clone(),
status: String::new(),
revised_prompt: None,
result: String::new(),
saved_path: None,
};
});
self.upsert_item_in_current_turn(item);
}
fn handle_image_generation_end(&mut self, payload: &ImageGenerationEndEvent) {
let item = ThreadItem::ImageGeneration {
let item = ThreadItem::ImageGeneration(ImageGenerationItem {
id: payload.call_id.clone(),
status: payload.status.clone(),
revised_prompt: payload.revised_prompt.clone(),
result: payload.result.clone(),
saved_path: payload.saved_path.clone(),
};
});
self.upsert_item_in_current_turn(item);
}
@@ -1548,6 +1550,7 @@ impl From<&PendingTurn> for Turn {
mod tests {
use super::*;
use crate::protocol::v2::CommandExecutionSource;
use codex_extension_items::ExtensionItem as CoreExtensionItem;
use codex_protocol::ThreadId;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem;
use codex_protocol::items::CommandExecutionItem as CoreCommandExecutionItem;
@@ -1846,6 +1849,60 @@ mod tests {
);
}
#[test]
fn rebuilds_extension_image_generation_item_from_persisted_completion() {
let turn_id = "turn-1";
let thread_id = ThreadId::new();
let saved_path = test_path_buf("/tmp/image-1.png").abs();
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id,
turn_id: turn_id.to_string(),
item: CoreTurnItem::Extension(CoreExtensionItem::ImageGeneration(
ImageGenerationItem {
id: "image-1".to_string(),
status: "completed".to_string(),
revised_prompt: Some("A blue square".to_string()),
result: "cG5n".to_string(),
saved_path: Some(saved_path.clone()),
},
)),
completed_at_ms: 1_000,
}),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_id.to_string(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
];
let items = events
.into_iter()
.map(RolloutItem::EventMsg)
.collect::<Vec<_>>();
let turns = build_turns_from_rollout_items(&items);
assert_eq!(
turns[0].items,
vec![ThreadItem::ImageGeneration(ImageGenerationItem {
id: "image-1".to_string(),
status: "completed".to_string(),
revised_prompt: Some("A blue square".to_string()),
result: "cG5n".to_string(),
saved_path: Some(saved_path),
})]
);
}
#[test]
fn rebuilds_command_execution_item_from_persisted_completion() {
let turn_id = "turn-1";
@@ -2058,13 +2115,13 @@ mod tests {
text_elements: Vec::new(),
}],
},
ThreadItem::ImageGeneration {
ThreadItem::ImageGeneration(ImageGenerationItem {
id: "ig_123".into(),
status: "completed".into(),
revised_prompt: Some("final prompt".into()),
result: "Zm9v".into(),
saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()),
},
}),
],
}
);
@@ -78,6 +78,11 @@ pub enum LoginAccountParams {
Chatgpt {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
codex_streamlined_login: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
use_hosted_login_success_page: bool,
#[serde(default)]
#[ts(optional = nullable)]
app_brand: Option<LoginAppBrand>,
},
#[serde(rename = "chatgptDeviceCode")]
#[ts(rename = "chatgptDeviceCode")]
@@ -102,6 +107,16 @@ pub enum LoginAccountParams {
},
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(rename_all = "lowercase")]
#[ts(export_to = "v2/")]
pub enum LoginAppBrand {
#[default]
Codex,
Chatgpt,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
@@ -284,6 +299,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 +354,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)]
@@ -163,6 +163,7 @@ pub struct AnalyticsConfig {
pub enum AppToolApproval {
Auto,
Prompt,
Writes,
Approve,
}
@@ -11,6 +11,8 @@ use super::shared::v2_enum_from_core;
use crate::protocol::item_builders::command_actions_for_path_uri;
use crate::protocol::item_builders::convert_patch_changes;
use codex_experimental_api_macros::ExperimentalApi;
use codex_extension_items::ExtensionItem;
pub use codex_extension_items::image_generation::ImageGenerationItem;
use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction;
use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource;
use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource;
@@ -246,7 +248,10 @@ pub enum ThreadItem {
#[ts(rename_all = "camelCase")]
/// EXPERIMENTAL - proposed plan item content. The completed plan item is
/// authoritative and may not match the concatenation of `PlanDelta` text.
Plan { id: String, text: String },
Plan {
id: String,
text: String,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Reasoning {
@@ -373,26 +378,24 @@ pub enum ThreadItem {
#[ts(type = "number")]
duration_ms: u64,
},
ImageGeneration(ImageGenerationItem),
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
ImageGeneration {
EnteredReviewMode {
id: String,
status: String,
revised_prompt: Option<String>,
result: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
saved_path: Option<AbsolutePathBuf>,
review: String,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
EnteredReviewMode { id: String, review: String },
ExitedReviewMode {
id: String,
review: String,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
ExitedReviewMode { id: String, review: String },
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
ContextCompaction { id: String },
ContextCompaction {
id: String,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -432,10 +435,10 @@ impl ThreadItem {
| ThreadItem::WebSearch { id, .. }
| ThreadItem::ImageView { id, .. }
| ThreadItem::Sleep { id, .. }
| ThreadItem::ImageGeneration { id, .. }
| ThreadItem::EnteredReviewMode { id, .. }
| ThreadItem::ExitedReviewMode { id, .. }
| ThreadItem::ContextCompaction { id, .. } => id,
ThreadItem::ImageGeneration(item) => &item.id,
}
}
}
@@ -931,13 +934,18 @@ impl From<CoreTurnItem> for ThreadItem {
id: sleep.id,
duration_ms: sleep.duration_ms,
},
CoreTurnItem::ImageGeneration(image) => ThreadItem::ImageGeneration {
id: image.id,
status: image.status,
revised_prompt: image.revised_prompt,
result: image.result,
saved_path: image.saved_path,
CoreTurnItem::Extension(extension) => match extension {
ExtensionItem::ImageGeneration(item) => ThreadItem::ImageGeneration(item),
},
CoreTurnItem::ImageGeneration(image) => {
ThreadItem::ImageGeneration(ImageGenerationItem {
id: image.id,
status: image.status,
revised_prompt: image.revised_prompt,
result: image.result,
saved_path: image.saved_path,
})
}
CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange {
id: file_change.id,
changes: convert_patch_changes(&file_change.changes),
@@ -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,
}
@@ -1722,7 +1722,9 @@ impl CodexClient {
let request = ClientRequest::LoginAccount {
request_id: request_id.clone(),
params: codex_app_server_protocol::LoginAccountParams::Chatgpt {
app_brand: None,
codex_streamlined_login: false,
use_hosted_login_success_page: false,
},
};
+44 -10
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.
@@ -1301,7 +1301,7 @@ Event notifications are the server-initiated event stream for thread lifecycles,
Thread realtime uses a separate thread-scoped notification surface. `thread/realtime/*` notifications are ephemeral transport events, not `ThreadItem`s, and are not returned by `thread/read`, `thread/resume`, or `thread/fork`.
Recoverable configuration and initialization warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization for config parsing and related setup diagnostics.
Recoverable configuration and initialization warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization for config parsing and related setup diagnostics, or to the requesting connection during `thread/start` when that thread's exec-policy rules fail to parse.
Generic runtime warnings use the `warning` notification: `{ threadId?, message }`. App-server emits this for non-fatal warnings from the core event stream, including cases where not all enabled skills are included in the model-visible skills list for a session.
@@ -1879,7 +1879,9 @@ review when allowed by configuration requirements.
Use `apps._default.default_tools_approval_mode` to set the approval mode for
tools without a per-app or per-tool override. Supported values are `"auto"`,
`"prompt"`, and `"approve"`. Tool-level `approval_mode` takes precedence over
`"prompt"`, `"writes"`, and `"approve"`. The `"writes"` mode prompts for tools
that do not advertise `readOnlyHint = true` and skips declared read-only tools.
Tool-level `approval_mode` takes precedence over
the per-app `default_tools_approval_mode`, which takes precedence over the
`apps._default` value. Managed tool requirements take precedence over all of
these settings. When none are configured, the mode defaults to `"auto"`.
@@ -1929,8 +1931,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.
@@ -1993,6 +1995,11 @@ Field notes:
{ "id": 3, "result": { "type": "chatgpt", "loginId": "<uuid>", "authUrl": "https://chatgpt.com/…&redirect_uri=http%3A%2F%2Flocalhost%3A<port>%2Fauth%2Fcallback" } }
```
2. Open `authUrl` in a browser; the app-server hosts the local callback.
By default, a successful callback redirects to the local success page. Clients may set
`useHostedLoginSuccessPage: true` to redirect successful callbacks that do not require
organization setup to the hosted Codex success page instead. When hosted login success is
enabled, clients may set `appBrand` to `"codex"` or `"chatgpt"` to select the matching hosted
page artwork; omitted or `null` values default to `"codex"`.
3. Wait for notifications:
```json
{ "method": "account/login/completed", "params": { "loginId": "<uuid>", "success": true, "error": null } }
@@ -2032,7 +2039,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 +2073,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)
+1
View File
@@ -12,6 +12,7 @@ pub(crate) fn auth_mode_to_api(auth_mode: AuthMode) -> ApiAuthMode {
AuthMode::ApiKey => ApiAuthMode::ApiKey,
AuthMode::Chatgpt => ApiAuthMode::Chatgpt,
AuthMode::ChatgptAuthTokens => ApiAuthMode::ChatgptAuthTokens,
AuthMode::Headers => ApiAuthMode::Headers,
AuthMode::AgentIdentity => ApiAuthMode::AgentIdentity,
AuthMode::PersonalAccessToken => ApiAuthMode::PersonalAccessToken,
AuthMode::BedrockApiKey => ApiAuthMode::BedrockApiKey,
+230 -148
View File
@@ -22,7 +22,6 @@ use codex_app_server_protocol::CommandExecutionSource;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::DeprecationNoticeNotification;
use codex_app_server_protocol::DynamicToolCallParams;
use codex_app_server_protocol::DynamicToolCallStatus;
use codex_app_server_protocol::ErrorNotification;
use codex_app_server_protocol::ExecPolicyAmendment as V2ExecPolicyAmendment;
use codex_app_server_protocol::FileChangeApprovalDecision;
@@ -90,6 +89,8 @@ use codex_core::ThreadManager;
use codex_core::review_format::format_review_findings_block;
use codex_core::review_prompts;
use codex_protocol::ThreadId;
use codex_protocol::items::CollabAgentTool as CoreCollabAgentTool;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::items::parse_hook_prompt_message;
use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile;
use codex_protocol::plan_tool::UpdatePlanArgs;
@@ -822,51 +823,8 @@ pub(crate) async fn apply_bespoke_event_handling(
on_request_permissions_response(pending_response, conversation, thread_state).await;
});
}
EventMsg::DynamicToolCallRequest(request) => {
let call_id = request.call_id;
let turn_id = request.turn_id;
let namespace = request.namespace;
let tool = request.tool;
let arguments = request.arguments;
let item = ThreadItem::DynamicToolCall {
id: call_id.clone(),
namespace: namespace.clone(),
tool: tool.clone(),
arguments: arguments.clone(),
status: DynamicToolCallStatus::InProgress,
content_items: None,
success: None,
duration_ms: None,
};
let notification = ItemStartedNotification {
thread_id: conversation_id.to_string(),
turn_id: turn_id.clone(),
started_at_ms: request.started_at_ms,
item,
};
outgoing
.send_server_notification(ServerNotification::ItemStarted(notification))
.await;
let params = DynamicToolCallParams {
thread_id: conversation_id.to_string(),
turn_id: turn_id.clone(),
call_id: call_id.clone(),
namespace,
tool: tool.clone(),
arguments: arguments.clone(),
};
let (_pending_request_id, rx) = outgoing
.send_request(ServerRequestPayload::DynamicToolCall(params))
.await;
tokio::spawn(async move {
crate::dynamic_tools::on_call_response(call_id, rx, conversation).await;
});
}
EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) => {
// Deprecated MCP tool-call events are still fanned out for legacy clients.
// App-server v2 receives the canonical TurnItem::McpToolCall lifecycle instead.
}
msg @ (EventMsg::DynamicToolCallResponse(_)
EventMsg::DynamicToolCallRequest(_)
| EventMsg::DynamicToolCallResponse(_)
| EventMsg::CollabAgentSpawnBegin(_)
| EventMsg::CollabAgentSpawnEnd(_)
| EventMsg::CollabAgentInteractionBegin(_)
@@ -874,9 +832,23 @@ pub(crate) async fn apply_bespoke_event_handling(
| EventMsg::CollabWaitingBegin(_)
| EventMsg::CollabWaitingEnd(_)
| EventMsg::CollabCloseBegin(_)
| EventMsg::CollabCloseEnd(_)
| EventMsg::CollabResumeBegin(_)
| EventMsg::CollabResumeEnd(_)
| EventMsg::AgentMessageContentDelta(_)
| EventMsg::SubAgentActivity(_)
| EventMsg::ExecCommandBegin(_)
| EventMsg::ExecCommandEnd(_) => {
// Deprecated item lifecycle events are still fanned out for raw-event and rollout
// compatibility consumers.
// App-server v2 receives canonical TurnItem lifecycle instead, and dispatches
// dynamic tool requests from canonical DynamicToolCall starts.
}
EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) => {
// Deprecated MCP tool-call events are still fanned out for raw-event and rollout
// compatibility consumers.
// App-server v2 receives the canonical TurnItem::McpToolCall lifecycle instead.
}
msg @ (EventMsg::AgentMessageContentDelta(_)
| EventMsg::PlanDelta(_)
| EventMsg::ReasoningContentDelta(_)
| EventMsg::ReasoningRawContentDelta(_)
@@ -888,43 +860,9 @@ pub(crate) async fn apply_bespoke_event_handling(
);
outgoing.send_server_notification(notification).await;
}
EventMsg::SubAgentActivity(activity) => {
if activity.kind == SubAgentActivityKind::Interrupted
&& thread_manager
.get_thread(activity.agent_thread_id)
.await
.is_err()
{
thread_watch_manager
.remove_thread(&activity.agent_thread_id.to_string())
.await;
}
let notification = item_event_to_server_notification(
EventMsg::SubAgentActivity(activity),
&conversation_id.to_string(),
&event_turn_id,
);
outgoing.send_server_notification(notification).await;
}
EventMsg::CollabCloseEnd(end_event) => {
if thread_manager
.get_thread(end_event.receiver_thread_id)
.await
.is_err()
{
thread_watch_manager
.remove_thread(&end_event.receiver_thread_id.to_string())
.await;
}
let notification = item_event_to_server_notification(
EventMsg::CollabCloseEnd(end_event),
&conversation_id.to_string(),
&event_turn_id,
);
outgoing.send_server_notification(notification).await;
}
EventMsg::ContextCompacted(..) => {
// Core still fans out this deprecated event for legacy clients;
// Core still fans out this deprecated event for raw-event and rollout compatibility
// consumers;
// v2 clients receive the canonical ContextCompaction item instead.
}
EventMsg::DeprecationNotice(event) => {
@@ -1027,10 +965,63 @@ pub(crate) async fn apply_bespoke_event_handling(
.send_server_notification(ServerNotification::ItemCompleted(completed))
.await;
}
msg @ (EventMsg::ItemStarted(_)
| EventMsg::ItemCompleted(_)
| EventMsg::PatchApplyUpdated(_)
| EventMsg::TerminalInteraction(_)) => {
EventMsg::ItemStarted(event) => {
let should_emit = match &event.item {
// Approval and guardian flows can emit the command start notification before core
// emits the canonical item. Reuse the same set to suppress that duplicate.
CoreTurnItem::CommandExecution(item) => thread_state
.lock()
.await
.turn_summary
.command_execution_started
.insert(item.id.clone()),
_ => true,
};
let dynamic_tool_call_params = match &event.item {
CoreTurnItem::DynamicToolCall(item) => Some(DynamicToolCallParams {
thread_id: conversation_id.to_string(),
turn_id: event.turn_id.clone(),
call_id: item.id.clone(),
namespace: item.namespace.clone(),
tool: item.tool.clone(),
arguments: item.arguments.clone(),
}),
_ => None,
};
if should_emit {
let notification = item_event_to_server_notification(
EventMsg::ItemStarted(event),
&conversation_id.to_string(),
&event_turn_id,
);
outgoing.send_server_notification(notification).await;
}
if let Some(params) = dynamic_tool_call_params {
let call_id = params.call_id.clone();
let (_pending_request_id, rx) = outgoing
.send_request(ServerRequestPayload::DynamicToolCall(params))
.await;
tokio::spawn(async move {
crate::dynamic_tools::on_call_response(call_id, rx, conversation).await;
});
}
}
EventMsg::ItemCompleted(event) => {
apply_canonical_item_completed_side_effects(
&thread_manager,
&thread_watch_manager,
&thread_state,
&event.item,
)
.await;
let notification = item_event_to_server_notification(
EventMsg::ItemCompleted(event),
&conversation_id.to_string(),
&event_turn_id,
);
outgoing.send_server_notification(notification).await;
}
msg @ (EventMsg::PatchApplyUpdated(_) | EventMsg::TerminalInteraction(_)) => {
let notification = item_event_to_server_notification(
msg,
&conversation_id.to_string(),
@@ -1103,36 +1094,10 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
}
EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyEnd(_) => {
// Core still fans out these deprecated events for legacy clients;
// Core still fans out these deprecated events for raw-event and rollout compatibility
// consumers;
// v2 clients receive the canonical FileChange item instead.
}
EventMsg::ExecCommandBegin(exec_command_begin_event) => {
if matches!(
exec_command_begin_event.source,
codex_protocol::protocol::ExecCommandSource::UnifiedExecInteraction
) {
// TerminalInteraction is the v2 surface for unified exec
// stdin/poll events. Suppress the legacy CommandExecution
// item so clients do not render the same wait twice.
return;
}
let item_id = exec_command_begin_event.call_id.clone();
let first_start = {
let mut state = thread_state.lock().await;
state
.turn_summary
.command_execution_started
.insert(item_id.clone())
};
if first_start {
let notification = item_event_to_server_notification(
EventMsg::ExecCommandBegin(exec_command_begin_event),
&conversation_id.to_string(),
&event_turn_id,
);
outgoing.send_server_notification(notification).await;
}
}
EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => {
let notification = item_event_to_server_notification(
EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event),
@@ -1141,31 +1106,6 @@ pub(crate) async fn apply_bespoke_event_handling(
);
outgoing.send_server_notification(notification).await;
}
EventMsg::ExecCommandEnd(exec_command_end_event) => {
let call_id = exec_command_end_event.call_id.clone();
{
let mut state = thread_state.lock().await;
state
.turn_summary
.command_execution_started
.remove(&call_id);
}
if matches!(
exec_command_end_event.source,
codex_protocol::protocol::ExecCommandSource::UnifiedExecInteraction
) {
// The paired begin event is suppressed above; keep the
// completion out of v2 as well so no orphan legacy item is
// emitted for unified exec interactions.
return;
}
let notification = item_event_to_server_notification(
EventMsg::ExecCommandEnd(exec_command_end_event),
&conversation_id.to_string(),
&event_turn_id,
);
outgoing.send_server_notification(notification).await;
}
// If this is a TurnAborted, reply to any pending interrupt requests.
EventMsg::TurnAborted(turn_aborted_event) => {
// All per-thread requests are bound to a turn, so abort them.
@@ -1369,6 +1309,52 @@ async fn emit_turn_completed_with_status(
.await;
}
async fn apply_canonical_item_completed_side_effects(
thread_manager: &Arc<ThreadManager>,
thread_watch_manager: &ThreadWatchManager,
thread_state: &Arc<Mutex<ThreadState>>,
item: &CoreTurnItem,
) {
match item {
CoreTurnItem::CommandExecution(item) => {
thread_state
.lock()
.await
.turn_summary
.command_execution_started
.remove(&item.id);
}
CoreTurnItem::SubAgentActivity(activity)
if activity.kind == SubAgentActivityKind::Interrupted =>
{
remove_missing_thread_watch(
thread_manager,
thread_watch_manager,
activity.agent_thread_id,
)
.await;
}
CoreTurnItem::CollabAgentToolCall(item) if item.tool == CoreCollabAgentTool::CloseAgent => {
for thread_id in &item.receiver_thread_ids {
remove_missing_thread_watch(thread_manager, thread_watch_manager, *thread_id).await;
}
}
_ => {}
}
}
async fn remove_missing_thread_watch(
thread_manager: &Arc<ThreadManager>,
thread_watch_manager: &ThreadWatchManager,
thread_id: ThreadId,
) {
if thread_manager.get_thread(thread_id).await.is_err() {
thread_watch_manager
.remove_thread(&thread_id.to_string())
.await;
}
}
#[allow(clippy::too_many_arguments)]
async fn start_command_execution_item(
conversation_id: &ThreadId,
@@ -2190,10 +2176,15 @@ mod tests {
use codex_app_server_protocol::AutoReviewDecisionSource;
use codex_app_server_protocol::GuardianApprovalReviewStatus;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::TurnPlanStepStatus;
use codex_login::CodexAuth;
use codex_protocol::AgentPath;
use codex_protocol::items::DynamicToolCallItem;
use codex_protocol::items::DynamicToolCallStatus as CoreDynamicToolCallStatus;
use codex_protocol::items::HookPromptFragment;
use codex_protocol::items::SubAgentActivityItem;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::items::build_hook_prompt_message;
use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions;
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
@@ -2210,11 +2201,12 @@ mod tests {
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GuardianAssessmentEvent;
use codex_protocol::protocol::GuardianAssessmentStatus;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentActivityEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::UserMessageEvent;
@@ -3437,13 +3429,17 @@ mod tests {
apply_bespoke_event_handling(
Event {
id: "turn-1".to_string(),
msg: EventMsg::SubAgentActivity(SubAgentActivityEvent {
event_id: "activity-1".to_string(),
occurred_at_ms: 42,
agent_thread_id: child_thread_id,
agent_path: AgentPath::try_from("/root/worker")
.expect("agent path should parse"),
kind: SubAgentActivityKind::Interrupted,
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: conversation_id,
turn_id: "turn-1".to_string(),
item: CoreTurnItem::SubAgentActivity(SubAgentActivityItem {
id: "activity-1".to_string(),
kind: SubAgentActivityKind::Interrupted,
agent_thread_id: child_thread_id,
agent_path: AgentPath::try_from("/root/worker")
.expect("agent path should parse"),
}),
completed_at_ms: 42,
}),
},
conversation_id,
@@ -3487,6 +3483,92 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn canonical_dynamic_tool_start_emits_item_and_requests_client() -> Result<()> {
let codex_home = TempDir::new()?;
let config = load_default_config_for_test(&codex_home).await;
let thread_manager = Arc::new(
codex_core::test_support::thread_manager_with_models_provider_and_home(
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
),
);
let codex_core::NewThread {
thread_id: conversation_id,
thread: conversation,
..
} = thread_manager.start_thread(config).await?;
let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY);
let outgoing = Arc::new(OutgoingMessageSender::new(
tx,
codex_analytics::AnalyticsEventsClient::disabled(),
));
let outgoing = ThreadScopedOutgoingMessageSender::new(
outgoing,
vec![ConnectionId(1)],
conversation_id,
);
apply_bespoke_event_handling(
Event {
id: "turn-1".to_string(),
msg: EventMsg::ItemStarted(ItemStartedEvent {
thread_id: conversation_id,
turn_id: "turn-1".to_string(),
item: CoreTurnItem::DynamicToolCall(DynamicToolCallItem {
id: "dynamic-1".to_string(),
namespace: Some("apps".to_string()),
tool: "lookup".to_string(),
arguments: json!({"id": "123"}),
status: CoreDynamicToolCallStatus::InProgress,
content_items: None,
success: None,
error: None,
duration: None,
}),
started_at_ms: 42,
}),
},
conversation_id,
conversation,
thread_manager,
outgoing,
new_thread_state(),
ThreadWatchManager::new(),
Arc::new(tokio::sync::Semaphore::new(/*permits*/ 1)),
"test-provider".to_string(),
)
.await;
let item_started = recv_broadcast_message(&mut rx).await?;
let OutgoingMessage::AppServerNotification(ServerNotification::ItemStarted(payload)) =
item_started
else {
bail!("unexpected message: {item_started:?}");
};
assert_eq!(payload.item.id(), "dynamic-1");
let request = recv_broadcast_message(&mut rx).await?;
let OutgoingMessage::Request(ServerRequest::DynamicToolCall { params, .. }) = request
else {
bail!("unexpected message: {request:?}");
};
assert_eq!(
params,
DynamicToolCallParams {
thread_id: conversation_id.to_string(),
turn_id: "turn-1".to_string(),
call_id: "dynamic-1".to_string(),
namespace: Some("apps".to_string()),
tool: "lookup".to_string(),
arguments: json!({"id": "123"}),
}
);
Ok(())
}
#[tokio::test]
async fn test_handle_turn_complete_emits_completed_without_error() -> Result<()> {
let conversation_id = ThreadId::new();
+95
View File
@@ -0,0 +1,95 @@
use std::sync::Arc;
use std::sync::RwLock;
use codex_app_server_protocol::ChatgptAuthTokensRefreshParams;
use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
use codex_app_server_protocol::ServerRequestPayload;
use codex_login::CodexAuth;
use codex_login::ExternalAuthFuture;
use codex_login::auth::ExternalAuth;
use codex_login::auth::ExternalAuthRefreshContext;
use codex_login::auth::ExternalAuthRefreshReason;
use tokio::time::Duration;
use tokio::time::timeout;
use crate::outgoing_message::OutgoingMessageSender;
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) struct ExternalAuthBridge {
outgoing: Arc<OutgoingMessageSender>,
auth: RwLock<CodexAuth>,
}
impl ExternalAuthBridge {
pub(crate) fn new(outgoing: Arc<OutgoingMessageSender>, auth: CodexAuth) -> Self {
Self {
outgoing,
auth: RwLock::new(auth),
}
}
async fn refresh(&self, context: ExternalAuthRefreshContext) -> std::io::Result<CodexAuth> {
let reason = match context.reason {
ExternalAuthRefreshReason::Unauthorized => ChatgptAuthTokensRefreshReason::Unauthorized,
};
let params = ChatgptAuthTokensRefreshParams {
reason,
previous_account_id: context.previous_account_id,
};
let (request_id, rx) = self
.outgoing
.send_request(ServerRequestPayload::ChatgptAuthTokensRefresh(params))
.await;
let result = match timeout(EXTERNAL_AUTH_REFRESH_TIMEOUT, rx).await {
Ok(result) => {
let result = result.map_err(|err| {
std::io::Error::other(format!("auth refresh request canceled: {err}"))
})?;
result.map_err(|err| {
std::io::Error::other(format!(
"auth refresh request failed: code={} message={}",
err.code, err.message
))
})?
}
Err(_) => {
let _canceled = self.outgoing.cancel_request(&request_id).await;
return Err(std::io::Error::other(format!(
"auth refresh request timed out after {}s",
EXTERNAL_AUTH_REFRESH_TIMEOUT.as_secs()
)));
}
};
let response: ChatgptAuthTokensRefreshResponse =
serde_json::from_value(result).map_err(std::io::Error::other)?;
let auth = CodexAuth::from_external_chatgpt_tokens(
response.access_token.as_str(),
response.chatgpt_account_id.as_str(),
response.chatgpt_plan_type.as_deref(),
)?;
*self
.auth
.write()
.map_err(|_| std::io::Error::other("external auth lock is poisoned"))? = auth.clone();
Ok(auth)
}
}
impl ExternalAuth for ExternalAuthBridge {
fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> {
Box::pin(async {
self.auth
.read()
.map(|auth| auth.clone())
.map_err(|_| std::io::Error::other("external auth lock is poisoned"))
})
}
fn refresh(&self, context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> {
Box::pin(ExternalAuthBridge::refresh(self, context))
}
}
+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?;
+12 -8
View File
@@ -96,6 +96,7 @@ mod current_time;
mod dynamic_tools;
mod error_code;
mod extensions;
mod external_auth;
mod filters;
mod fs_watch;
mod fuzzy_file_search;
@@ -320,6 +321,16 @@ fn exec_policy_warning_location(err: &ExecPolicyError) -> (Option<String>, Optio
}
}
fn exec_policy_config_warning(err: &ExecPolicyError) -> ConfigWarningNotification {
let (path, range) = exec_policy_warning_location(err);
ConfigWarningNotification {
summary: "Error parsing rules; custom rules not applied.".to_string(),
details: Some(err.to_string()),
path,
range,
}
}
fn app_text_range(range: &CoreTextRange) -> AppTextRange {
AppTextRange {
start: AppTextPosition {
@@ -612,14 +623,7 @@ pub async fn run_main_with_transport_options(
}
if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.config_layer_stack).await {
let (path, range) = exec_policy_warning_location(&err);
let message = ConfigWarningNotification {
summary: "Error parsing rules; custom rules not applied.".to_string(),
details: Some(err.to_string()),
path,
range,
};
config_warnings.push(message);
config_warnings.push(exec_policy_config_warning(&err));
}
if let Some(warning) = project_config_warning(&config) {
+2 -88
View File
@@ -50,9 +50,6 @@ use crate::transport::AppServerTransport;
use crate::transport::RemoteControlHandle;
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::AppServerRpcTransport;
use codex_app_server_protocol::ChatgptAuthTokensRefreshParams;
use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ClientResponsePayload;
@@ -63,7 +60,6 @@ use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::experimental_required_message;
use codex_arg0::Arg0DispatchPaths;
use codex_chatgpt::workspace_settings;
@@ -74,12 +70,7 @@ use codex_feedback::CodexFeedback;
use codex_goal_extension::GoalService;
use codex_home::CodexHomeUserInstructionsProvider;
use codex_login::AuthManager;
use codex_login::auth::ExternalAuth;
use codex_login::auth::ExternalAuthRefreshContext;
use codex_login::auth::ExternalAuthRefreshReason;
use codex_login::auth::ExternalAuthTokens;
use codex_protocol::ThreadId;
use codex_protocol::auth::AuthMode as LoginAuthMode;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::W3cTraceContext;
use codex_rollout::StateDbHandle;
@@ -95,7 +86,6 @@ use tracing::Instrument;
use crate::models_refresh_worker::ModelsRefreshWorker;
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
const CONNECTION_RPC_DRAIN_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30);
fn deserialize_client_request(
@@ -109,80 +99,6 @@ fn deserialize_client_request(
})
}
#[derive(Clone)]
struct ExternalAuthRefreshBridge {
outgoing: Arc<OutgoingMessageSender>,
}
impl ExternalAuthRefreshBridge {
fn map_reason(reason: ExternalAuthRefreshReason) -> ChatgptAuthTokensRefreshReason {
match reason {
ExternalAuthRefreshReason::Unauthorized => ChatgptAuthTokensRefreshReason::Unauthorized,
}
}
async fn refresh(
&self,
context: ExternalAuthRefreshContext,
) -> std::io::Result<ExternalAuthTokens> {
let params = ChatgptAuthTokensRefreshParams {
reason: Self::map_reason(context.reason),
previous_account_id: context.previous_account_id,
};
let (request_id, rx) = self
.outgoing
.send_request(ServerRequestPayload::ChatgptAuthTokensRefresh(params))
.await;
let result = match timeout(EXTERNAL_AUTH_REFRESH_TIMEOUT, rx).await {
Ok(result) => {
// Two failure scenarios:
// 1) `oneshot::Receiver` failed (sender dropped) => request canceled/channel closed.
// 2) client answered with JSON-RPC error payload => propagate code/message.
let result = result.map_err(|err| {
std::io::Error::other(format!("auth refresh request canceled: {err}"))
})?;
result.map_err(|err| {
std::io::Error::other(format!(
"auth refresh request failed: code={} message={}",
err.code, err.message
))
})?
}
Err(_) => {
let _canceled = self.outgoing.cancel_request(&request_id).await;
return Err(std::io::Error::other(format!(
"auth refresh request timed out after {}s",
EXTERNAL_AUTH_REFRESH_TIMEOUT.as_secs()
)));
}
};
let response: ChatgptAuthTokensRefreshResponse =
serde_json::from_value(result).map_err(std::io::Error::other)?;
Ok(ExternalAuthTokens::chatgpt(
response.access_token,
response.chatgpt_account_id,
response.chatgpt_plan_type,
))
}
}
impl ExternalAuth for ExternalAuthRefreshBridge {
fn auth_mode(&self) -> LoginAuthMode {
LoginAuthMode::Chatgpt
}
fn refresh(
&self,
context: ExternalAuthRefreshContext,
) -> codex_login::ExternalAuthFuture<'_, ExternalAuthTokens> {
Box::pin(ExternalAuthRefreshBridge::refresh(self, context))
}
}
pub(crate) struct MessageProcessor {
outgoing: Arc<OutgoingMessageSender>,
models_refresh_worker: ModelsRefreshWorker,
@@ -327,9 +243,6 @@ impl MessageProcessor {
remote_control_handle,
plugin_startup_tasks,
} = args;
auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge {
outgoing: outgoing.clone(),
}));
let thread_state_manager = ThreadStateManager::new();
// The thread store is intentionally process-scoped. Config reloads can
// affect per-thread behavior, but they must not move newly started,
@@ -447,7 +360,7 @@ impl MessageProcessor {
outgoing.clone(),
analytics_events_client.clone(),
Arc::clone(&config),
config_warnings,
config_warnings.clone(),
rpc_transport,
);
let marketplace_processor = MarketplaceRequestProcessor::new(
@@ -495,6 +408,7 @@ impl MessageProcessor {
state_db.clone(),
log_db,
Arc::clone(&skills_watcher),
config_warnings,
);
let turn_processor = TurnRequestProcessor::new(
auth_manager.clone(),
@@ -94,6 +94,7 @@ use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::LoginApiKeyParams;
use codex_app_server_protocol::LoginAppBrand;
use codex_app_server_protocol::LogoutAccountResponse;
use codex_app_server_protocol::MarketplaceAddParams;
use codex_app_server_protocol::MarketplaceAddResponse;
@@ -156,7 +157,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 +302,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;
@@ -374,10 +380,12 @@ use codex_feedback::FeedbackUploadOptions;
use codex_git_utils::git_diff_to_remote;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::AuthManager;
use codex_login::CODEX_OPEN_APP_URL;
use codex_login::CodexAuth;
use codex_login::LoginSuccessPage;
use codex_login::LoginSuccessPageBrand;
use codex_login::ServerOptions as LoginServerOptions;
use codex_login::ShutdownHandle;
use codex_login::auth::login_with_chatgpt_auth_tokens;
use codex_login::complete_device_code_login;
use codex_login::login_with_api_key;
use codex_login::oauth_client_id;
@@ -1,5 +1,6 @@
use super::*;
use crate::auth_mode::auth_mode_to_api;
use crate::external_auth::ExternalAuthBridge;
use chrono::DateTime;
mod rate_limit_resets;
@@ -9,9 +10,11 @@ const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
const ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT: Duration =
Duration::from_millis(/*millis*/ 1000);
// The override is intentionally available only in debug builds, matching the login path below.
// Login overrides are intentionally available only in debug builds.
#[cfg(debug_assertions)]
const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
#[cfg(debug_assertions)]
const LOGIN_OPEN_APP_URL_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_DEV_OPEN_APP_URL";
enum ActiveLogin {
Browser {
@@ -253,9 +256,25 @@ impl AccountRequestProcessor {
.await;
}
LoginAccountParams::Chatgpt {
app_brand,
codex_streamlined_login,
use_hosted_login_success_page,
} => {
self.login_chatgpt_v2(request_id, codex_streamlined_login)
let login_success_page = if use_hosted_login_success_page {
let app_brand = match app_brand.unwrap_or_default() {
LoginAppBrand::Codex => LoginSuccessPageBrand::Codex,
LoginAppBrand::Chatgpt => LoginSuccessPageBrand::Chatgpt,
};
LoginSuccessPage::Hosted {
url: CODEX_OPEN_APP_URL.parse().map_err(|err| {
internal_error(format!("invalid Codex open app URL: {err}"))
})?,
app_brand,
}
} else {
LoginSuccessPage::default()
};
self.login_chatgpt_v2(request_id, codex_streamlined_login, login_success_page)
.await;
}
LoginAccountParams::ChatgptDeviceCode => {
@@ -341,6 +360,7 @@ impl AccountRequestProcessor {
async fn login_chatgpt_common(
&self,
codex_streamlined_login: bool,
login_success_page: LoginSuccessPage,
) -> std::result::Result<LoginServerOptions, JSONRPCErrorError> {
let config = self.config.as_ref();
@@ -357,6 +377,7 @@ impl AccountRequestProcessor {
let opts = LoginServerOptions {
open_browser: false,
codex_streamlined_login,
login_success_page,
..LoginServerOptions::new(
config.codex_home.to_path_buf(),
oauth_client_id(),
@@ -374,6 +395,14 @@ impl AccountRequestProcessor {
{
opts.issuer = issuer;
}
if let LoginSuccessPage::Hosted { url, .. } = &mut opts.login_success_page
&& let Ok(open_app_url) = std::env::var(LOGIN_OPEN_APP_URL_OVERRIDE_ENV_VAR)
&& !open_app_url.trim().is_empty()
{
*url = open_app_url
.parse()
.map_err(|err| internal_error(format!("invalid Codex open app URL: {err}")))?;
}
opts
};
@@ -393,16 +422,22 @@ impl AccountRequestProcessor {
&self,
request_id: ConnectionRequestId,
codex_streamlined_login: bool,
login_success_page: LoginSuccessPage,
) {
let result = self.login_chatgpt_response(codex_streamlined_login).await;
let result = self
.login_chatgpt_response(codex_streamlined_login, login_success_page)
.await;
self.outgoing.send_result(request_id, result).await;
}
async fn login_chatgpt_response(
&self,
codex_streamlined_login: bool,
login_success_page: LoginSuccessPage,
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
let opts = self.login_chatgpt_common(codex_streamlined_login).await?;
let opts = self
.login_chatgpt_common(codex_streamlined_login, login_success_page)
.await?;
let server = run_login_server(opts)
.map_err(|err| internal_error(format!("failed to start login server: {err}")))?;
let login_id = Uuid::new_v4();
@@ -474,7 +509,10 @@ impl AccountRequestProcessor {
&self,
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
let opts = self
.login_chatgpt_common(/*codex_streamlined_login*/ false)
.login_chatgpt_common(
/*codex_streamlined_login*/ false,
LoginSuccessPage::default(),
)
.await?;
let device_code = request_device_code(&opts)
.await
@@ -617,14 +655,19 @@ impl AccountRequestProcessor {
)));
}
login_with_chatgpt_auth_tokens(
&self.config.codex_home,
let auth = CodexAuth::from_external_chatgpt_tokens(
&access_token,
&chatgpt_account_id,
chatgpt_plan_type.as_deref(),
)
.map_err(|err| internal_error(format!("failed to set external auth: {err}")))?;
self.auth_manager.reload().await;
self.auth_manager
.set_external_auth(Arc::new(ExternalAuthBridge::new(
Arc::clone(&self.outgoing),
auth,
)))
.await
.map_err(|err| internal_error(format!("failed to set external auth: {err}")))?;
self.config_manager.replace_cloud_config_bundle_loader(
self.auth_manager.clone(),
self.config.chatgpt_base_url.clone(),
@@ -812,7 +855,9 @@ impl AccountRequestProcessor {
let auth_mode = auth_mode_to_api(auth.api_auth_mode());
let (reported_auth_method, token_opt) = if matches!(
auth,
CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_)
CodexAuth::Headers(_)
| CodexAuth::AgentIdentity(_)
| CodexAuth::PersonalAccessToken(_)
) || include_token
&& permanent_refresh_failure
{
@@ -891,9 +936,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 +967,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 +984,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}"))
}
@@ -47,6 +47,8 @@ impl AppsRequestProcessor {
request_id: &ConnectionRequestId,
params: AppsListParams,
) -> Result<Option<AppsListResponse>, JSONRPCErrorError> {
let installed_start = Instant::now();
let reload = params.force_refetch;
let thread = if let Some(thread_id) = params.thread_id.as_deref() {
let (_, loaded_thread) = self.load_thread(thread_id).await?;
Some(loaded_thread)
@@ -70,20 +72,24 @@ impl AppsRequestProcessor {
.features
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))
{
return Ok(Some(AppsListResponse {
let response = AppsListResponse {
data: Vec::new(),
next_cursor: None,
}));
};
record_legacy_apps_installed_duration(installed_start, reload);
return Ok(Some(response));
}
if !self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await
{
return Ok(Some(AppsListResponse {
let response = AppsListResponse {
data: Vec::new(),
next_cursor: None,
}));
};
record_legacy_apps_installed_duration(installed_start, reload);
return Ok(Some(response));
}
let request = request_id.clone();
@@ -103,6 +109,7 @@ impl AppsRequestProcessor {
environment_manager,
mcp_manager,
plugins_manager,
installed_start,
) => {}
}
});
@@ -113,6 +120,7 @@ impl AppsRequestProcessor {
self.shutdown_token.cancel();
}
#[allow(clippy::too_many_arguments)]
async fn apps_list_task(
outgoing: Arc<OutgoingMessageSender>,
request_id: ConnectionRequestId,
@@ -121,7 +129,9 @@ impl AppsRequestProcessor {
environment_manager: Arc<EnvironmentManager>,
mcp_manager: Arc<McpManager>,
plugins_manager: Arc<PluginsManager>,
installed_start: Instant,
) {
let reload = params.force_refetch;
let retry_params = params.clone();
let retry_config = config.clone();
let retry_environment_manager = Arc::clone(&environment_manager);
@@ -136,6 +146,9 @@ impl AppsRequestProcessor {
plugins_manager,
)
.await;
if result.is_ok() {
record_legacy_apps_installed_duration(installed_start, reload);
}
let should_retry = result
.as_ref()
.is_ok_and(|(_, codex_apps_ready)| !codex_apps_ready);
@@ -363,6 +376,20 @@ impl AppsRequestProcessor {
}
const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90);
// `app/list` is the legacy request-path baseline for the future `app/installed` endpoint;
// `path=legacy` keeps it separate from the new snapshot-backed implementation in dashboards.
const APPS_INSTALLED_DURATION_METRIC: &str = "codex.apps.installed.duration_ms";
fn record_legacy_apps_installed_duration(started_at: Instant, reload: bool) {
let reload = if reload { "true" } else { "false" };
if let Some(metrics) = codex_otel::global() {
let _ = metrics.record_duration(
APPS_INSTALLED_DURATION_METRIC,
started_at.elapsed(),
&[("path", "legacy"), ("reload", reload)],
);
}
}
enum AppListLoadResult {
Accessible(Result<AccessibleConnectorsStatus, String>),
@@ -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),
@@ -1546,6 +1548,7 @@ impl PluginRequestProcessor {
&remote_marketplace_name,
/*plugin_id*/ None,
error_type,
/*sub_error_type*/ None,
err.to_string(),
);
remote_plugin_catalog_error_to_jsonrpc(
@@ -1589,11 +1592,13 @@ impl PluginRequestProcessor {
)
.map_err(|err| {
let error_type = remote_plugin_bundle_install_error_type(&err);
let sub_error_type = err.sub_error_type();
self.track_plugin_install_failed_for_remote_plugin(
&remote_plugin_id,
&actual_remote_marketplace_name,
Some(&resolved_plugin_id),
error_type,
sub_error_type,
err.to_string(),
);
remote_plugin_bundle_install_error_to_jsonrpc(err)
@@ -1606,11 +1611,13 @@ impl PluginRequestProcessor {
.await
.map_err(|err| {
let error_type = remote_plugin_bundle_install_error_type(&err);
let sub_error_type = err.sub_error_type();
self.track_plugin_install_failed_for_remote_plugin(
&remote_plugin_id,
&actual_remote_marketplace_name,
Some(&resolved_plugin_id),
error_type,
sub_error_type,
err.to_string(),
);
remote_plugin_bundle_install_error_to_jsonrpc(err)
@@ -1633,6 +1640,7 @@ impl PluginRequestProcessor {
&actual_remote_marketplace_name,
Some(&result.plugin_id),
error_type,
/*sub_error_type*/ None,
err.to_string(),
);
remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin")
@@ -1730,12 +1738,14 @@ impl PluginRequestProcessor {
marketplace_name: &str,
plugin_id: Option<&PluginId>,
error_type: &'static str,
sub_error_type: Option<String>,
error_message: String,
) {
tracing::warn!(
remote_plugin_id = %remote_plugin_id,
marketplace_name = %marketplace_name,
error_type = %error_type,
sub_error_type = sub_error_type.as_deref(),
error = %error_message,
"remote plugin install failed"
);
@@ -2180,6 +2190,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,
@@ -364,6 +364,7 @@ pub(crate) struct ThreadRequestProcessor {
pub(super) log_db: Option<LogDbLayer>,
pub(super) background_tasks: TaskTracker,
pub(super) skills_watcher: Arc<SkillsWatcher>,
pub(super) initial_config_warnings: Arc<Vec<ConfigWarningNotification>>,
}
/// Outcome of trying to satisfy a resume request from an already loaded thread.
@@ -395,6 +396,7 @@ impl ThreadRequestProcessor {
state_db: Option<StateDbHandle>,
log_db: Option<LogDbLayer>,
skills_watcher: Arc<SkillsWatcher>,
initial_config_warnings: Vec<ConfigWarningNotification>,
) -> Self {
Self {
auth_manager,
@@ -413,6 +415,7 @@ impl ThreadRequestProcessor {
log_db,
background_tasks: TaskTracker::new(),
skills_watcher,
initial_config_warnings: Arc::new(initial_config_warnings),
}
}
@@ -968,6 +971,7 @@ impl ThreadRequestProcessor {
};
let request_trace = request_context.request_trace();
let config_manager = self.config_manager.clone();
let initial_config_warnings = Arc::clone(&self.initial_config_warnings);
let outgoing = Arc::clone(&listener_task_context.outgoing);
let error_request_id = request_id.clone();
let thread_start_task = async move {
@@ -990,6 +994,7 @@ impl ThreadRequestProcessor {
allow_provider_model_fallback,
experimental_raw_events,
request_trace,
initial_config_warnings,
)
.await
{
@@ -1066,6 +1071,7 @@ impl ThreadRequestProcessor {
allow_provider_model_fallback: bool,
experimental_raw_events: bool,
request_trace: Option<W3cTraceContext>,
initial_config_warnings: Arc<Vec<ConfigWarningNotification>>,
) -> Result<(), JSONRPCErrorError> {
let thread_start_started_at = std::time::Instant::now();
let requested_cwd = typesafe_overrides.cwd.clone();
@@ -1139,6 +1145,21 @@ impl ThreadRequestProcessor {
.map_err(|err| config_load_error(&err))?;
}
if let Ok(Some(err)) =
codex_core::check_execpolicy_for_warnings(&config.config_layer_stack).await
{
let notification = crate::exec_policy_config_warning(&err);
if !initial_config_warnings.contains(&notification) {
listener_task_context
.outgoing
.send_server_notification_to_connections(
&[request_id.connection_id],
ServerNotification::ConfigWarning(notification),
)
.await;
}
}
let environments = environments.unwrap_or_else(|| {
listener_task_context
.thread_manager
@@ -32,7 +32,7 @@ pub(super) fn redact_thread_resume_payloads(turns: &mut [Turn]) {
}
true
}
ThreadItem::ImageGeneration { .. } => false,
ThreadItem::ImageGeneration(_) => false,
_ => true,
});
}
@@ -52,6 +52,7 @@ fn redacted_mcp_tool_call_result() -> McpToolCallResult {
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::ImageGenerationItem;
use codex_app_server_protocol::McpToolCallAppContext;
use codex_app_server_protocol::McpToolCallError;
use codex_app_server_protocol::McpToolCallStatus;
@@ -100,13 +101,13 @@ mod tests {
error: None,
duration_ms: Some(8),
},
ThreadItem::ImageGeneration {
ThreadItem::ImageGeneration(ImageGenerationItem {
id: "ig-1".to_string(),
status: "completed".to_string(),
revised_prompt: Some("revised".to_string()),
result: "base64-result".to_string(),
saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()),
},
}),
]);
redact_thread_resume_payloads(&mut thread.turns);
@@ -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,
@@ -27,6 +27,7 @@ codex-protocol = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"macros",
+1
View File
@@ -49,6 +49,7 @@ use serde::de::DeserializeOwned;
pub use test_app_server::DEFAULT_CLIENT_NAME;
pub use test_app_server::DISABLE_PLUGIN_STARTUP_TASKS_ARG;
pub use test_app_server::TestAppServer;
pub use test_app_server::TestAppServerBuilder;
pub fn to_response<T: DeserializeOwned>(response: JSONRPCResponse) -> anyhow::Result<T> {
let value = serde_json::to_value(response.result)?;
@@ -1,5 +1,6 @@
use std::collections::VecDeque;
use std::path::Path;
use std::path::PathBuf;
use std::process::ExitStatus;
use std::process::Stdio;
use std::sync::atomic::AtomicI64;
@@ -111,6 +112,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;
@@ -121,6 +123,7 @@ use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR;
use codex_login::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR;
use core_test_support::test_codex::TestEnv;
use core_test_support::test_codex::test_env;
use tempfile::TempDir;
use tokio::process::Command;
use crate::json_logging::JsonLogCapture;
@@ -137,6 +140,8 @@ pub struct TestAppServer {
pending_messages: VecDeque<JSONRPCMessage>,
auto_env: Option<TestEnv>,
json_logs: JsonLogCapture,
codex_home: PathBuf,
_owned_codex_home: Option<TempDir>,
}
pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests";
@@ -144,12 +149,28 @@ pub const DISABLE_PLUGIN_STARTUP_TASKS_ARG: &str = "--disable-plugin-startup-tas
const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG";
impl TestAppServer {
/// Starts building a server with a temporary CODEX_HOME and the standard
/// automatic test environment.
pub fn builder() -> TestAppServerBuilder {
TestAppServerBuilder {
codex_home: None,
environment: TestAppServerEnvironment::Auto,
program: None,
env_overrides: Vec::new(),
args: vec![DISABLE_PLUGIN_STARTUP_TASKS_ARG.to_string()],
}
}
pub async fn wait_for_exit(&mut self) -> std::io::Result<ExitStatus> {
self.process.wait().await
}
pub async fn new(codex_home: &Path) -> anyhow::Result<Self> {
Self::new_with_env_and_args(codex_home, &[], &[DISABLE_PLUGIN_STARTUP_TASKS_ARG]).await
Self::builder()
.with_codex_home(codex_home)
.without_auto_env()
.build()
.await
}
/// Starts an app server with the standard test environment and retains it
@@ -163,7 +184,7 @@ impl TestAppServer {
/// URL-based configuration, this helper rejects a `codex_home` containing
/// that file.
pub async fn new_with_auto_env(codex_home: &Path) -> anyhow::Result<Self> {
Self::new_with_auto_env_and_env(codex_home, &[]).await
Self::builder().with_codex_home(codex_home).build().await
}
/// Starts an auto-environment app server that emits JSON logs.
@@ -173,64 +194,39 @@ impl TestAppServer {
codex_home: &Path,
rust_log: impl Into<String>,
) -> anyhow::Result<Self> {
let rust_log = rust_log.into();
Self::new_with_auto_env_and_env(
codex_home,
&[
("LOG_FORMAT", Some("json")),
("RUST_LOG", Some(rust_log.as_str())),
],
)
.await
Self::builder()
.with_codex_home(codex_home)
.with_json_logging(rust_log)
.build()
.await
}
async fn new_with_auto_env_and_env(
codex_home: &Path,
extra_env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
let environments_toml = codex_home.join("environments.toml");
ensure!(
!environments_toml
.try_exists()
.with_context(|| format!("check whether {} exists", environments_toml.display()))?,
"new_with_auto_env cannot be used when {} exists",
environments_toml.display()
);
let auto_env = test_env().await?;
// Noise registry configuration takes precedence over the URL-based
// provider, so clear inherited values to keep the selection hermetic.
let mut env_overrides = vec![
(
CODEX_EXEC_SERVER_URL_ENV_VAR,
auto_env.environment().exec_server_url(),
),
(CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR, None),
(CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR, None),
(CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR, None),
(CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR, None),
];
env_overrides.extend_from_slice(extra_env_overrides);
let mut app_server = Self::new_with_env(codex_home, &env_overrides).await?;
app_server.auto_env = Some(auto_env);
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(),
})
}
/// Returns the effective CODEX_HOME used by the child app-server.
pub fn codex_home(&self) -> &Path {
&self.codex_home
}
/// Waits for a JSON stderr event whose structured `event.name` field matches.
pub async fn wait_for_json_log_event(
&self,
@@ -254,33 +250,56 @@ impl TestAppServer {
}
pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result<Self> {
Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await
Self::builder()
.with_codex_home(codex_home)
.without_auto_env()
.without_managed_config()
.build()
.await
}
pub async fn new_without_managed_config_with_env(
codex_home: &Path,
env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
let mut all_env_overrides = vec![(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))];
all_env_overrides.extend_from_slice(env_overrides);
Self::new_with_env(codex_home, &all_env_overrides).await
Self::builder()
.with_codex_home(codex_home)
.without_auto_env()
.without_managed_config()
.with_env_overrides(env_overrides)
.build()
.await
}
pub async fn new_with_plugin_startup_tasks(codex_home: &Path) -> anyhow::Result<Self> {
Self::new_with_env_and_args(codex_home, &[], &[]).await
Self::builder()
.with_codex_home(codex_home)
.without_auto_env()
.with_plugin_startup_tasks()
.build()
.await
}
pub async fn new_with_env_and_plugin_startup_tasks(
codex_home: &Path,
env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
Self::new_with_env_and_args(codex_home, env_overrides, &[]).await
Self::builder()
.with_codex_home(codex_home)
.without_auto_env()
.with_plugin_startup_tasks()
.with_env_overrides(env_overrides)
.build()
.await
}
pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result<Self> {
let mut all_args = vec![DISABLE_PLUGIN_STARTUP_TASKS_ARG];
all_args.extend_from_slice(args);
Self::new_with_env_and_args(codex_home, &[], &all_args).await
Self::builder()
.with_codex_home(codex_home)
.without_auto_env()
.with_args(args)
.build()
.await
}
/// Creates a new MCP process, allowing tests to override or remove
@@ -292,12 +311,12 @@ impl TestAppServer {
codex_home: &Path,
env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
Self::new_with_env_and_args(
codex_home,
env_overrides,
&[DISABLE_PLUGIN_STARTUP_TASKS_ARG],
)
.await
Self::builder()
.with_codex_home(codex_home)
.without_auto_env()
.with_env_overrides(env_overrides)
.build()
.await
}
pub async fn new_with_program_and_env(
@@ -305,23 +324,13 @@ impl TestAppServer {
program: &Path,
env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
Self::new_with_program_env_and_args(
codex_home,
program,
env_overrides,
&[DISABLE_PLUGIN_STARTUP_TASKS_ARG],
)
.await
}
async fn new_with_env_and_args(
codex_home: &Path,
env_overrides: &[(&str, Option<&str>)],
args: &[&str],
) -> anyhow::Result<Self> {
let program = codex_utils_cargo_bin::cargo_bin("codex-app-server")
.context("should find binary for codex-app-server")?;
Self::new_with_program_env_and_args(codex_home, &program, env_overrides, args).await
Self::builder()
.with_codex_home(codex_home)
.without_auto_env()
.with_program(program)
.with_env_overrides(env_overrides)
.build()
.await
}
async fn new_with_program_env_and_args(
@@ -392,6 +401,8 @@ impl TestAppServer {
pending_messages: VecDeque::new(),
auto_env: None,
json_logs,
codex_home: codex_home.to_path_buf(),
_owned_codex_home: None,
})
}
@@ -546,8 +557,8 @@ impl TestAppServer {
chatgpt_account_id,
chatgpt_plan_type,
};
let params = Some(serde_json::to_value(params)?);
self.send_request("account/login/start", params).await
self.send_login_account_request(serde_json::to_value(params)?)
.await
}
/// Send a `feedback/upload` JSON-RPC request.
@@ -1052,6 +1063,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,
@@ -1380,6 +1424,14 @@ impl TestAppServer {
self.send_request("account/logout", /*params*/ None).await
}
/// Send an `account/login/start` JSON-RPC request.
pub async fn send_login_account_request(
&mut self,
params: serde_json::Value,
) -> anyhow::Result<i64> {
self.send_request("account/login/start", Some(params)).await
}
/// Send an `account/login/start` JSON-RPC request for API key login.
pub async fn send_login_account_api_key_request(
&mut self,
@@ -1389,7 +1441,7 @@ impl TestAppServer {
"type": "apiKey",
"apiKey": api_key,
});
self.send_request("account/login/start", Some(params)).await
self.send_login_account_request(params).await
}
/// Send an `account/login/start` JSON-RPC request for ChatGPT login.
@@ -1397,7 +1449,7 @@ impl TestAppServer {
let params = serde_json::json!({
"type": "chatgpt"
});
self.send_request("account/login/start", Some(params)).await
self.send_login_account_request(params).await
}
/// Send an `account/login/start` JSON-RPC request for ChatGPT device code login.
@@ -1405,7 +1457,7 @@ impl TestAppServer {
let params = serde_json::json!({
"type": "chatgptDeviceCode"
});
self.send_request("account/login/start", Some(params)).await
self.send_login_account_request(params).await
}
/// Send an `account/login/cancel` JSON-RPC request.
@@ -1752,6 +1804,164 @@ impl TestAppServer {
}
}
/// Builder for TestAppServer.
pub struct TestAppServerBuilder {
codex_home: Option<PathBuf>,
environment: TestAppServerEnvironment,
program: Option<PathBuf>,
env_overrides: Vec<(String, Option<String>)>,
args: Vec<String>,
}
enum TestAppServerEnvironment {
Auto,
None,
}
impl TestAppServerBuilder {
/// Uses this existing CODEX_HOME instead of a temporary one.
pub fn with_codex_home(mut self, codex_home: &Path) -> Self {
self.codex_home = Some(codex_home.to_path_buf());
self
}
/// Starts app-server without the standard automatic test environment.
pub fn without_auto_env(mut self) -> Self {
self.environment = TestAppServerEnvironment::None;
self
}
/// Uses this app-server binary instead of the standard test binary.
pub fn with_program(mut self, program: &Path) -> Self {
self.program = Some(program.to_path_buf());
self
}
/// Adds command-line arguments after the default test arguments.
pub fn with_args(mut self, args: &[&str]) -> Self {
self.args
.extend(args.iter().map(|argument| (*argument).to_string()));
self
}
/// Enables startup tasks that the default test arguments disable.
pub fn with_plugin_startup_tasks(mut self) -> Self {
self.args
.retain(|argument| argument != DISABLE_PLUGIN_STARTUP_TASKS_ARG);
self
}
/// Adds child-process environment overrides.
///
/// Some values set variables and None values remove inherited variables.
pub fn with_env_overrides(mut self, env_overrides: &[(&str, Option<&str>)]) -> Self {
self.env_overrides
.extend(env_overrides.iter().map(|(key, value)| {
(
(*key).to_string(),
value.map(std::string::ToString::to_string),
)
}));
self
}
/// Prevents the child from loading managed configuration.
pub fn without_managed_config(self) -> Self {
self.with_env_overrides(&[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))])
}
/// Configures the child to emit JSON logs at the requested Rust log level.
pub fn with_json_logging(self, rust_log: impl Into<String>) -> Self {
let rust_log = rust_log.into();
let mut builder = self.with_env_overrides(&[("LOG_FORMAT", Some("json"))]);
builder
.env_overrides
.push(("RUST_LOG".to_string(), Some(rust_log)));
builder
}
/// Builds a server with a temporary CODEX_HOME and automatic environment
/// by default.
pub async fn build(self) -> anyhow::Result<TestAppServer> {
let Self {
codex_home,
environment,
program,
mut env_overrides,
args,
} = self;
let (codex_home, owned_codex_home) = match codex_home {
Some(codex_home) => (codex_home, None),
None => {
let owned_codex_home = TempDir::new()?;
(
owned_codex_home.path().to_path_buf(),
Some(owned_codex_home),
)
}
};
let auto_env = match environment {
TestAppServerEnvironment::Auto => {
let environments_toml = codex_home.join("environments.toml");
ensure!(
!environments_toml.try_exists().with_context(|| format!(
"check whether {} exists",
environments_toml.display()
))?,
"automatic environment cannot be used when {} exists",
environments_toml.display()
);
let auto_env = test_env().await?;
// Noise registry configuration takes precedence over the URL-based
// provider, so clear inherited values to keep the selection hermetic.
let mut auto_env_overrides = vec![
(
CODEX_EXEC_SERVER_URL_ENV_VAR.to_string(),
auto_env.environment().exec_server_url().map(str::to_string),
),
(
CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR.to_string(),
None,
),
(
CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR.to_string(),
None,
),
(CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR.to_string(), None),
(
CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR.to_string(),
None,
),
];
auto_env_overrides.append(&mut env_overrides);
env_overrides = auto_env_overrides;
Some(auto_env)
}
TestAppServerEnvironment::None => None,
};
let program = match program {
Some(program) => program,
None => codex_utils_cargo_bin::cargo_bin("codex-app-server")
.context("should find binary for codex-app-server")?,
};
let env_overrides = env_overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_deref()))
.collect::<Vec<_>>();
let args = args.iter().map(String::as_str).collect::<Vec<_>>();
let mut app_server = TestAppServer::new_with_program_env_and_args(
&codex_home,
&program,
&env_overrides,
&args,
)
.await?;
app_server.auto_env = auto_env;
app_server._owned_codex_home = owned_codex_home;
Ok(app_server)
}
}
impl Drop for TestAppServer {
fn drop(&mut self) {
// These tests spawn a `codex-app-server` child process.
+59 -31
View File
@@ -115,8 +115,12 @@ async fn get_auth_status_no_auth() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path())?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -142,7 +146,11 @@ async fn get_auth_status_with_api_key() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
login_with_api_key_via_request(&mut mcp, "sk-test-key").await?;
@@ -186,15 +194,16 @@ async fn personal_access_token_without_email_supports_auth_status_and_account_re
.await;
let authapi_base_url = server.uri();
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
("CODEX_ACCESS_TOKEN", Some("at-test-token")),
("CODEX_AUTHAPI_BASE_URL", Some(authapi_base_url.as_str())),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -256,7 +265,11 @@ async fn get_auth_status_with_api_key_when_auth_not_required() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml_custom_provider(codex_home.path(), /*requires_openai_auth*/ false)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
login_with_api_key_via_request(&mut mcp, "sk-test-key").await?;
@@ -289,7 +302,11 @@ async fn get_auth_status_with_api_key_no_include_token() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
login_with_api_key_via_request(&mut mcp, "sk-test-key").await?;
@@ -317,7 +334,11 @@ async fn get_auth_status_with_api_key_refresh_requested() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
login_with_api_key_via_request(&mut mcp, "sk-test-key").await?;
@@ -373,17 +394,18 @@ async fn get_auth_status_omits_token_after_permanent_refresh_failure() -> Result
.await;
let refresh_url = format!("{}/oauth/token", server.uri());
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
(
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
Some(refresh_url.as_str()),
),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -455,17 +477,18 @@ async fn get_auth_status_omits_token_after_proactive_refresh_failure() -> Result
.await;
let refresh_url = format!("{}/oauth/token", server.uri());
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
(
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
Some(refresh_url.as_str()),
),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -522,17 +545,18 @@ async fn get_auth_status_returns_token_after_proactive_refresh_recovery() -> Res
.await;
let refresh_url = format!("{}/oauth/token", server.uri());
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
(
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
Some(refresh_url.as_str()),
),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let failed_request_id = mcp
@@ -599,7 +623,11 @@ async fn login_api_key_rejected_when_forced_chatgpt() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml_forced_login(codex_home.path(), "chatgpt")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -92,7 +92,11 @@ async fn get_conversation_summary_by_thread_id_reads_rollout() -> Result<()> {
))?,
);
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -217,7 +221,11 @@ async fn get_conversation_summary_by_relative_rollout_path_resolves_from_codex_h
let relative_path = rollout_path.strip_prefix(codex_home.path())?.to_path_buf();
let expected = expected_summary(thread_id, normalized_canonical_path(rollout_path)?);
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -46,7 +46,11 @@ shell_snapshot = false
async fn initialized_mcp(codex_home: &TempDir) -> Result<TestAppServer> {
create_config_toml(codex_home.path())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
Ok(mcp)
}
@@ -236,7 +240,11 @@ async fn test_fuzzy_file_search_sorts_and_includes_indices() -> Result<()> {
.to_string();
// Start MCP server and initialize.
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let root_path = root.path().to_string_lossy().to_string();
@@ -302,7 +310,11 @@ async fn test_fuzzy_file_search_accepts_cancellation_token() -> Result<()> {
std::fs::write(root.path().join("alpha.txt"), "contents")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let root_path = root.path().to_string_lossy().to_string();
+5 -5
View File
@@ -68,11 +68,11 @@ async fn app_server_emits_structured_tool_call_timing_event() -> Result<()> {
"compact",
)?;
let mut app_server = TestAppServer::new_with_auto_env_and_json_logging(
codex_home.path(),
"warn,codex_core::tools::parallel=info",
)
.await?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_json_logging("warn,codex_core::tools::parallel=info")
.build()
.await?;
timeout(READ_TIMEOUT, app_server.initialize()).await??;
let thread_start_id = app_server
+259 -71
View File
@@ -57,6 +57,7 @@ use wiremock::matchers::path;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
const LOGIN_ISSUER_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
const LOGIN_OPEN_APP_URL_ENV_VAR: &str = "CODEX_APP_SERVER_DEV_OPEN_APP_URL";
const WORKSPACE_ID_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174000";
const WORKSPACE_ID_SECOND_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174001";
const WORKSPACE_ID_DISALLOWED: &str = "123e4567-e89b-42d3-a456-426614174002";
@@ -181,7 +182,7 @@ async fn mock_device_code_token_failure(server: &MockServer, status: u16) {
.await;
}
async fn mock_device_code_oauth_token(server: &MockServer, id_token: &str) {
async fn mock_oauth_token(server: &MockServer, id_token: &str) {
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
@@ -206,8 +207,12 @@ async fn logout_account_removes_auth_and_notifies() -> Result<()> {
)?;
assert!(codex_home.path().join("auth.json").exists());
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let id = mcp.send_logout_account_request().await?;
@@ -274,8 +279,12 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
.chatgpt_account_id(WORKSPACE_ID_EMBEDDED),
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let set_id = mcp
@@ -327,6 +336,27 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
}
);
let logout_id = mcp.send_logout_account_request().await?;
let logout_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(logout_id)),
)
.await??;
let _: LogoutAccountResponse = to_response(logout_resp)?;
let get_id = mcp
.send_get_account_request(GetAccountParams {
refresh_token: false,
})
.await?;
let get_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(get_id)),
)
.await??;
let account: GetAccountResponse = to_response(get_resp)?;
assert_eq!(account.account, None);
Ok(())
}
@@ -349,8 +379,12 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> {
.chatgpt_account_id(WORKSPACE_ID_EMBEDDED),
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let set_id = mcp
@@ -475,8 +509,12 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> {
.chatgpt_account_id(WORKSPACE_ID_REFRESHED),
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let set_id = mcp
@@ -583,8 +621,12 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> {
.chatgpt_account_id(WORKSPACE_ID_INITIAL),
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let set_id = mcp
@@ -707,8 +749,12 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> {
.chatgpt_account_id(WORKSPACE_ID_DISALLOWED),
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let set_id = mcp
@@ -824,8 +870,12 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> {
.chatgpt_account_id(WORKSPACE_ID_INITIAL),
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let set_id = mcp
@@ -918,7 +968,11 @@ async fn login_account_api_key_succeeds_and_notifies() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let req_id = mcp
@@ -972,7 +1026,11 @@ async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> {
},
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -1002,7 +1060,11 @@ async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> {
},
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_request().await?;
@@ -1035,14 +1097,15 @@ async fn login_account_chatgpt_device_code_returns_error_when_disabled() -> Resu
mock_device_code_usercode_failure(&mock_server, /*status*/ 404).await;
let issuer = mock_server.uri();
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
(LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_device_code_request().await?;
@@ -1097,17 +1160,18 @@ async fn login_account_chatgpt_device_code_succeeds_and_notifies() -> Result<()>
.plan_type("pro")
.chatgpt_account_id(WORKSPACE_ID_DEVICE),
)?;
mock_device_code_oauth_token(&mock_server, &id_token).await;
mock_oauth_token(&mock_server, &id_token).await;
let issuer = mock_server.uri();
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
(LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_device_code_request().await?;
@@ -1177,14 +1241,15 @@ async fn login_account_chatgpt_device_code_failure_notifies_without_account_upda
mock_device_code_token_failure(&mock_server, /*status*/ 500).await;
let issuer = mock_server.uri();
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
(LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_device_code_request().await?;
@@ -1252,14 +1317,15 @@ async fn login_account_chatgpt_device_code_can_be_cancelled() -> Result<()> {
mock_device_code_token_failure(&mock_server, /*status*/ 404).await;
let issuer = mock_server.uri();
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
(LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_device_code_request().await?;
@@ -1325,7 +1391,11 @@ async fn login_account_chatgpt_start_can_be_cancelled() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_request().await?;
@@ -1391,14 +1461,15 @@ async fn login_account_chatgpt_uses_debug_oauth_overrides() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
(CLIENT_ID_OVERRIDE_ENV_VAR, Some("staging-client")),
(LOGIN_ISSUER_ENV_VAR, Some("https://auth.example.com")),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_request().await?;
@@ -1436,6 +1507,78 @@ async fn login_account_chatgpt_uses_debug_oauth_overrides() -> Result<()> {
Ok(())
}
#[tokio::test]
// Serialize tests that launch the login server since it binds to a fixed port.
#[serial(login_port)]
async fn login_account_chatgpt_redirects_to_hosted_success_page() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
let mock_server = MockServer::start().await;
let id_token = encode_id_token(
&ChatGptIdTokenClaims::new()
.email("hosted@example.com")
.plan_type("pro")
.chatgpt_account_id(WORKSPACE_ID_EMBEDDED),
)?;
mock_oauth_token(&mock_server, &id_token).await;
let issuer = mock_server.uri();
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
(LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),
(
LOGIN_OPEN_APP_URL_ENV_VAR,
Some("http://localhost:3000/codex/open-app"),
),
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_login_account_request(json!({
"type": "chatgpt",
"appBrand": "chatgpt",
"useHostedLoginSuccessPage": true,
}))
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let login: LoginAccountResponse = to_response(resp)?;
let LoginAccountResponse::Chatgpt { auth_url, .. } = login else {
bail!("unexpected login response: {login:?}");
};
let auth_url = Url::parse(&auth_url)?;
let callback_url = auth_url
.query_pairs()
.find_map(|(key, value)| (key == "redirect_uri").then(|| value.into_owned()))
.ok_or_else(|| anyhow::anyhow!("missing redirect_uri"))?;
let state = auth_url
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.ok_or_else(|| anyhow::anyhow!("missing state"))?;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let response = client
.get(format!("{callback_url}?code=test-code&state={state}"))
.send()
.await?;
assert_eq!(response.status(), 302);
assert_eq!(
response.headers()["location"].to_str()?,
"http://localhost:3000/codex/open-app?source=login&app_brand=chatgpt"
);
Ok(())
}
#[tokio::test]
// Serialize tests that launch the login server since it binds to a fixed port.
#[serial(login_port)]
@@ -1443,7 +1586,11 @@ async fn set_auth_token_cancels_active_chatgpt_login() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
// Initiate the ChatGPT login flow
@@ -1518,7 +1665,11 @@ async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result
},
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_request().await?;
@@ -1555,7 +1706,11 @@ async fn login_account_chatgpt_includes_forced_workspace_allowlist_query_param()
},
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_request().await?;
@@ -1594,8 +1749,12 @@ async fn get_account_no_auth() -> Result<()> {
},
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
@@ -1626,7 +1785,11 @@ async fn get_account_with_api_key() -> Result<()> {
},
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let req_id = mcp
@@ -1670,7 +1833,11 @@ async fn get_account_when_auth_not_required() -> Result<()> {
},
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
@@ -1711,7 +1878,11 @@ region = "us-west-2"
},
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
@@ -1754,7 +1925,11 @@ async fn get_account_with_managed_bedrock_provider() -> Result<()> {
AuthKeyringBackendKind::default(),
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -1799,8 +1974,12 @@ async fn get_account_with_chatgpt() -> Result<()> {
AuthCredentialsStoreMode::File,
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
@@ -1842,8 +2021,12 @@ async fn get_account_with_chatgpt_without_email() -> Result<()> {
AuthCredentialsStoreMode::File,
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -1905,17 +2088,18 @@ async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<(
.await;
let refresh_url = format!("{}/oauth/token", server.uri());
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
(
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
Some(refresh_url.as_str()),
),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let auth_status_request_id = mcp
@@ -1971,8 +2155,12 @@ async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result
AuthCredentialsStoreMode::File,
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
+155 -14
View File
@@ -8,7 +8,9 @@ use std::time::Duration;
use anyhow::Result;
use anyhow::bail;
use app_test_support::ChatGptAuthFixture;
use app_test_support::ChatGptIdTokenClaims;
use app_test_support::TestAppServer;
use app_test_support::encode_id_token;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use axum::Json;
@@ -29,6 +31,7 @@ use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadStartParams;
@@ -63,7 +66,11 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
#[tokio::test]
async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
@@ -129,7 +136,11 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> {
AuthKeyringBackendKind::default(),
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -156,6 +167,89 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn list_apps_uses_external_chatgpt_auth() -> Result<()> {
let access_token = encode_id_token(
&ChatGptIdTokenClaims::new()
.email("external@example.com")
.plan_type("pro")
.chatgpt_account_id("account-123"),
)?;
let connectors = vec![AppInfo {
id: "beta".to_string(),
name: "Beta".to_string(),
description: Some("Beta connector".to_string()),
logo_url: None,
logo_url_dark: None,
icon_assets: None,
icon_dark_assets: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}];
let tools = vec![connector_tool("beta", "Beta App")?];
let (server_url, server_handle, _) = start_apps_server_with_delays_and_control_inner(
connectors,
tools,
Duration::ZERO,
Duration::ZERO,
/*workspace_plugins_enabled*/ true,
&access_token,
)
.await?;
let codex_home = TempDir::new()?;
write_connectors_config(codex_home.path(), &server_url)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let login_id = mcp
.send_chatgpt_auth_tokens_login_request(
access_token,
"account-123".to_string(),
Some("pro".to_string()),
)
.await?;
let login_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(login_id)),
)
.await??;
assert_eq!(
to_response::<LoginAccountResponse>(login_response)?,
LoginAccountResponse::ChatgptAuthTokens {}
);
let request_id = mcp
.send_apps_list_request(AppsListParams {
limit: None,
cursor: None,
thread_id: None,
force_refetch: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let AppsListResponse { data, next_cursor } = to_response(response)?;
assert_eq!(data.len(), 1);
assert_eq!(data[0].id, "beta");
assert!(data[0].is_accessible);
assert!(next_cursor.is_none());
server_handle.abort();
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn list_apps_returns_empty_when_workspace_codex_plugins_disabled() -> Result<()> {
let connectors = vec![AppInfo {
@@ -193,7 +287,12 @@ async fn list_apps_returns_empty_when_workspace_codex_plugins_disabled() -> Resu
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.without_managed_config()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -238,7 +337,11 @@ async fn list_apps_includes_plugin_apps_for_chatgpt_auth() -> Result<()> {
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -298,7 +401,10 @@ async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Resu
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let start_request = mcp
@@ -411,7 +517,11 @@ async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> {
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -486,7 +596,11 @@ enabled = false
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -609,7 +723,11 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -759,7 +877,11 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates()
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -871,7 +993,11 @@ async fn list_apps_does_not_emit_empty_interim_updates() -> Result<()> {
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -986,7 +1112,11 @@ async fn list_apps_paginates_results() -> Result<()> {
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let first_request = mcp
@@ -1112,7 +1242,11 @@ async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let initial_request = mcp
@@ -1242,7 +1376,11 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let warm_request = mcp
@@ -1557,6 +1695,7 @@ async fn start_apps_server_with_workspace_plugins_enabled(
Duration::ZERO,
Duration::ZERO,
workspace_plugins_enabled,
"chatgpt-token",
)
.await?;
Ok((server_url, server_handle))
@@ -1574,6 +1713,7 @@ async fn start_apps_server_with_delays_and_control(
directory_delay,
tools_delay,
/*workspace_plugins_enabled*/ true,
"chatgpt-token",
)
.await
}
@@ -1584,13 +1724,14 @@ async fn start_apps_server_with_delays_and_control_inner(
directory_delay: Duration,
tools_delay: Duration,
workspace_plugins_enabled: bool,
expected_bearer: &str,
) -> Result<(String, JoinHandle<()>, AppsServerControl)> {
let response = Arc::new(StdMutex::new(
json!({ "apps": connectors, "next_token": null }),
));
let tools = Arc::new(StdMutex::new(tools));
let state = AppsServerState {
expected_bearer: "Bearer chatgpt-token".to_string(),
expected_bearer: format!("Bearer {expected_bearer}"),
expected_account_id: "account-123".to_string(),
response: response.clone(),
directory_delay,
@@ -67,8 +67,12 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand
AuthCredentialsStoreMode::File,
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
let initialized = timeout(
DEFAULT_READ_TIMEOUT,
mcp.initialize_with_capabilities(
@@ -41,7 +41,10 @@ async fn thread_start_with_auto_env_exposes_fixture_cwd_to_model() -> Result<()>
"compact",
)?;
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let expected_environment = mcp.auto_env_params()?;
@@ -110,14 +113,17 @@ async fn auto_env_rejects_explicit_environment_config() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(codex_home.path().join("environments.toml"), "")?;
let result = TestAppServer::new_with_auto_env(codex_home.path()).await;
let result = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build()
.await;
let Err(err) = result else {
anyhow::bail!("auto-env construction unexpectedly succeeded");
};
assert_eq!(
err.to_string(),
format!(
"new_with_auto_env cannot be used when {} exists",
"automatic environment cannot be used when {} exists",
codex_home.path().join("environments.toml").display()
)
);

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