diff --git a/.bazelrc b/.bazelrc index de01a87443..93fcb0c164 100644 --- a/.bazelrc +++ b/.bazelrc @@ -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 diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index 39c40eb07f..ae0bd24ca8 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -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" diff --git a/.codex/environments/setup.py b/.codex/environments/setup.py deleted file mode 100644 index a9d78c9405..0000000000 --- a/.codex/environments/setup.py +++ /dev/null @@ -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() diff --git a/.github/actions/prepare-bazel-ci/action.yml b/.github/actions/prepare-bazel-ci/action.yml index b41d80e0bc..a1beece2df 100644 --- a/.github/actions/prepare-bazel-ci/action.yml +++ b/.github/actions/prepare-bazel-ci/action.yml @@ -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 diff --git a/.github/actions/run-argument-comment-lint/action.yml b/.github/actions/run-argument-comment-lint/action.yml index 80fb23d417..31c752e86e 100644 --- a/.github/actions/run-argument-comment-lint/action.yml +++ b/.github/actions/run-argument-comment-lint/action.yml @@ -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' }} diff --git a/.github/actions/setup-bazel-ci/action.yml b/.github/actions/setup-bazel-ci/action.yml index 658dca2aa4..57e71fbc78 100644 --- a/.github/actions/setup-bazel-ci/action.yml +++ b/.github/actions/setup-bazel-ci/action.yml @@ -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 diff --git a/.github/actions/setup-ci/action.yml b/.github/actions/setup-ci/action.yml new file mode 100644 index 0000000000..23c78a4952 --- /dev/null +++ b/.github/actions/setup-ci/action.yml @@ -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 diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml index 634d647e06..24f8632979 100644 --- a/.github/actions/windows-code-sign/action.yml +++ b/.github/actions/windows-code-sign/action.yml @@ -48,7 +48,7 @@ runs: { echo "files<> "$GITHUB_OUTPUT" diff --git a/.github/scripts/build-codex-package-archive.sh b/.github/scripts/build-codex-package-archive.sh index 229213cc53..4592e78ce5 100644 --- a/.github/scripts/build-codex-package-archive.sh +++ b/.github/scripts/build-codex-package-archive.sh @@ -9,6 +9,7 @@ Usage: build-codex-package-archive.sh \ --entrypoint-dir \ --archive-dir \ [--bwrap-bin ] \ + [--code-mode-host-bin ] \ [--zsh-manifest ] \ [--codex-command-runner-bin ] \ [--codex-windows-sandbox-setup-bin ] \ @@ -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}" diff --git a/.github/scripts/macos-signing/codex-app-server.entitlements.plist b/.github/scripts/macos-signing/codex-app-server.entitlements.plist new file mode 100644 index 0000000000..26b12f2886 --- /dev/null +++ b/.github/scripts/macos-signing/codex-app-server.entitlements.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/.github/scripts/macos-signing/codex-code-mode-host.entitlements.plist b/.github/scripts/macos-signing/codex-code-mode-host.entitlements.plist new file mode 100644 index 0000000000..26b12f2886 --- /dev/null +++ b/.github/scripts/macos-signing/codex-code-mode-host.entitlements.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/.github/scripts/macos-signing/codex-responses-api-proxy.entitlements.plist b/.github/scripts/macos-signing/codex-responses-api-proxy.entitlements.plist new file mode 100644 index 0000000000..d35e43ae58 --- /dev/null +++ b/.github/scripts/macos-signing/codex-responses-api-proxy.entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + + diff --git a/.github/scripts/macos-signing/codex.entitlements.plist b/.github/scripts/macos-signing/codex.entitlements.plist index d35e43ae58..26b12f2886 100644 --- a/.github/scripts/macos-signing/codex.entitlements.plist +++ b/.github/scripts/macos-signing/codex.entitlements.plist @@ -4,5 +4,7 @@ com.apple.security.cs.allow-jit + com.apple.security.cs.allow-unsigned-executable-memory + diff --git a/.github/scripts/rusty_v8_bazel.py b/.github/scripts/rusty_v8_bazel.py index 329d3f6c54..93360edfbf 100644 --- a/.github/scripts/rusty_v8_bazel.py +++ b/.github/scripts/rusty_v8_bazel.py @@ -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 diff --git a/.github/scripts/test_macos_signing_entitlements.py b/.github/scripts/test_macos_signing_entitlements.py new file mode 100644 index 0000000000..e56d5f3a1b --- /dev/null +++ b/.github/scripts/test_macos_signing_entitlements.py @@ -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() diff --git a/.github/scripts/test_rusty_v8_bazel.py b/.github/scripts/test_rusty_v8_bazel.py index 0b5c03f436..b90121bb43 100644 --- a/.github/scripts/test_rusty_v8_bazel.py +++ b/.github/scripts/test_rusty_v8_bazel.py @@ -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, ) diff --git a/.github/scripts/test_v8_canary_changes.py b/.github/scripts/test_v8_canary_changes.py index e4ce6424ed..0d8d9a2b32 100644 --- a/.github/scripts/test_v8_canary_changes.py +++ b/.github/scripts/test_v8_canary_changes.py @@ -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( diff --git a/.github/scripts/v8_canary_changes.py b/.github/scripts/v8_canary_changes.py index 0acc4ec3f6..53a064e384 100644 --- a/.github/scripts/v8_canary_changes.py +++ b/.github/scripts/v8_canary_changes.py @@ -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 diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 10dd8670de..3e48b741b7 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -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: diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index be00e1147d..ccbf80e12f 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -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 diff --git a/.github/workflows/repo-checks.yml b/.github/workflows/repo-checks.yml index 3d0504172d..97932c4fd4 100644 --- a/.github/workflows/repo-checks.yml +++ b/.github/workflows/repo-checks.yml @@ -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 diff --git a/.github/workflows/rust-ci-full-nextest-platform.yml b/.github/workflows/rust-ci-full-nextest-platform.yml index 65d1fb2be5..5f22c74b73 100644 --- a/.github/workflows/rust-ci-full-nextest-platform.yml +++ b/.github/workflows/rust-ci-full-nextest-platform.yml @@ -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 diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index cb99c8911e..c50c032067 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -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 diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index cf1afdfe2d..01d51aac0e 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -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 diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 7069b6ca8f..2fa8a52b7f 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -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 diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 49d24abc18..9fdd66637b 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -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 diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index 92be3761dd..a2f620aa8f 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -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 diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 53d9585108..d5f0133528 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -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 diff --git a/.worktreeinclude b/.worktreeinclude new file mode 100644 index 0000000000..74824b01f9 --- /dev/null +++ b/.worktreeinclude @@ -0,0 +1 @@ +user.bazelrc diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index d572f68f21..3356b66c14 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -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\":{}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6ac51d403f..9e250998ff 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -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", ] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 2f73c216a9..0c1a4f9711 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -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" } diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0f759fd938..0789c9de48 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -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 { diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 29f21bac52..fa742b74db 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -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 { }, })) } - 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 { 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(), }, }, )) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index b7a958eff1..4eec8a2348 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -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::*; diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index dc69e2d4bf..1f0cf450b3 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -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 } diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7f7ead662b..d391cac165 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index e7a533d76b..e025a61a33 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -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": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 53139c82d8..b7526b080d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 961dd14f3e..e1afc7883a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json index 5c1847c56c..710e0ecedd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -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": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 16f8363416..19863dd1f3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -78,6 +78,7 @@ "enum": [ "auto", "prompt", + "writes", "approve" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConsumeAccountRateLimitResetCreditParams.json b/codex-rs/app-server-protocol/schema/json/v2/ConsumeAccountRateLimitResetCreditParams.json index c9bf023a63..3d9d2c1e98 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConsumeAccountRateLimitResetCreditParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConsumeAccountRateLimitResetCreditParams.json @@ -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" diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json index 11b9712510..a2883a1293 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json index ab7b852c91..e6c82237d3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json @@ -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": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json index 55ad95be2e..f39b0f19b3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index 25d12e50dd..2f0c6e4b40 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index b4b917602a..628cb13189 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json index 751a6c5e7f..32bf946e38 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts b/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts index 77bc037ebe..b248876173 100644 --- a/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts +++ b/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/ImageGenerationItem.ts b/codex-rs/app-server-protocol/schema/typescript/ImageGenerationItem.ts new file mode 100644 index 0000000000..26cd6285bc --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ImageGenerationItem.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "./AbsolutePathBuf"; + +export type ImageGenerationItem = { id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index dcfecf1282..092aa3c5e4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts index e92cd8e28b..6704ef0fde 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type AppToolApproval = "auto" | "prompt" | "approve"; +export type AppToolApproval = "auto" | "prompt" | "writes" | "approve"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConsumeAccountRateLimitResetCreditParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConsumeAccountRateLimitResetCreditParams.ts index c3cef64f36..f1c5bf351e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConsumeAccountRateLimitResetCreditParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConsumeAccountRateLimitResetCreditParams.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts index e6f1e2ed43..1606f167fa 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts @@ -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. diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAppBrand.ts b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAppBrand.ts new file mode 100644 index 0000000000..c06d12ffff --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAppBrand.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type LoginAppBrand = "codex" | "chatgpt"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallPolicySource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallPolicySource.ts new file mode 100644 index 0000000000..caa39628ac --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallPolicySource.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginInstallPolicySource = "WORKSPACE_SETTING" | "IMPLICIT_CANONICAL_APP"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts index d12f55b3a3..10fd5aa25f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts @@ -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. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCredit.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCredit.ts new file mode 100644 index 0000000000..514c1558a2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCredit.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditStatus.ts new file mode 100644 index 0000000000..fa15861b3a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditStatus.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RateLimitResetCreditStatus = "available" | "redeeming" | "redeemed" | "unknown"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditsSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditsSummary.ts index e42dd8a703..46a8eee2e7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditsSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditsSummary.ts @@ -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 | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetType.ts new file mode 100644 index 0000000000..718145bf47 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RateLimitResetType = "codexRateLimits" | "unknown"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index c611ab071c..4cb8330b79 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index c405082c4c..070d21eab8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index d8db99d4ff..27a8b0205d 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -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, diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 2d9c6b6a1a..ca3849bb38 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -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::>(); + + 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()), - }, + }), ], } ); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 635d001bba..44551e400d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -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, }, #[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>, +} + +#[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, + /// Backend-provided display title for this credit, or `null` when unavailable. + pub title: Option, + /// Backend-provided display description for this credit, or `null` when unavailable. + pub description: Option, +} + +#[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, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index f615b25d44..24d574a935 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -163,6 +163,7 @@ pub struct AnalyticsConfig { pub enum AppToolApproval { Auto, Prompt, + Writes, Approve, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index cd04752449..2fea01da6a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -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, - result: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - saved_path: Option, + 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 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), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 19a4ccd889..9bd53b5d1a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -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, pub auth_policy: PluginAuthPolicy, /// Availability state for installing and using the plugin. #[serde(default)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs index 4b8b2056ea..b78f390bec 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs @@ -70,6 +70,10 @@ pub struct ThreadRealtimeStartParams { /// them automatically. Defaults to false. #[ts(optional = nullable)] pub client_managed_handoffs: Option, + /// 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, /// Sends automatic Codex responses as realtime conversation items instead of handoff appends. #[ts(optional = nullable)] pub codex_responses_as_items: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 77d8a1439b..377ecba2c2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -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, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 7cd03930d8..367a4a64c8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -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, } diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index a70945c468..0e32365d12 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -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, }, }; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 43e7dbd3eb..6ee6baf2dc 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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": "", "authUrl": "https://chatgpt.com/…&redirect_uri=http%3A%2F%2Flocalhost%3A%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": "", "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) diff --git a/codex-rs/app-server/src/auth_mode.rs b/codex-rs/app-server/src/auth_mode.rs index 67d3ee61b9..d543470781 100644 --- a/codex-rs/app-server/src/auth_mode.rs +++ b/codex-rs/app-server/src/auth_mode.rs @@ -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, diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index c63103161f..b6f47dfe82 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -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, + thread_watch_manager: &ThreadWatchManager, + thread_state: &Arc>, + 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, + 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(); diff --git a/codex-rs/app-server/src/external_auth.rs b/codex-rs/app-server/src/external_auth.rs new file mode 100644 index 0000000000..d00777f710 --- /dev/null +++ b/codex-rs/app-server/src/external_auth.rs @@ -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, + auth: RwLock, +} + +impl ExternalAuthBridge { + pub(crate) fn new(outgoing: Arc, auth: CodexAuth) -> Self { + Self { + outgoing, + auth: RwLock::new(auth), + } + } + + async fn refresh(&self, context: ExternalAuthRefreshContext) -> std::io::Result { + 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)) + } +} diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 938d539a9f..d8a2bc2276 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -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 { +pub async fn start(mut args: InProcessStartArgs) -> IoResult { + 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?; diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 0edb57d8a2..91da0c1645 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -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, 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) { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 375307e804..ca122920bd 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -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, -} - -impl ExternalAuthRefreshBridge { - fn map_reason(reason: ExternalAuthRefreshReason) -> ChatgptAuthTokensRefreshReason { - match reason { - ExternalAuthRefreshReason::Unauthorized => ChatgptAuthTokensRefreshReason::Unauthorized, - } - } - - async fn refresh( - &self, - context: ExternalAuthRefreshContext, - ) -> std::io::Result { - 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, 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(), diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 587325c246..42ec2063b4 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -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; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 4691e22204..f4acc5a8e4 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -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 { 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 { - 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 { 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, }) } diff --git a/codex-rs/app-server/src/request_processors/account_processor/rate_limit_resets.rs b/codex-rs/app-server/src/request_processors/account_processor/rate_limit_resets.rs index c8a397d3a7..919d30d770 100644 --- a/codex-rs/app-server/src/request_processors/account_processor/rate_limit_resets.rs +++ b/codex-rs/app-server/src/request_processors/account_processor/rate_limit_resets.rs @@ -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 { + 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::().ok()) .map(Duration::from_millis) .unwrap_or(request_timeout); - let response = tokio::time::timeout( - request_timeout, - client.consume_rate_limit_reset_credit(¶ms.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(¶ms.idempotency_key, credit_id) + .await + } + None => { + client + .consume_rate_limit_reset_credit(¶ms.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 { + let credits = details + .credits + .into_iter() + .map(rate_limit_reset_credit_from_backend) + .collect::, _>>()?; + Ok(RateLimitResetCreditsSummary { + available_count: details.available_count, + credits: Some(credits), + }) +} + +fn rate_limit_reset_credit_from_backend( + credit: BackendRateLimitResetCreditDetails, +) -> Result { + 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 { + DateTime::parse_from_rfc3339(timestamp) + .map(|timestamp| timestamp.timestamp()) + .map_err(|err| format!("failed to parse timestamp `{timestamp}`: {err}")) +} diff --git a/codex-rs/app-server/src/request_processors/apps_processor.rs b/codex-rs/app-server/src/request_processors/apps_processor.rs index 67d768fa57..5ff7ec40db 100644 --- a/codex-rs/app-server/src/request_processors/apps_processor.rs +++ b/codex-rs/app-server/src/request_processors/apps_processor.rs @@ -47,6 +47,8 @@ impl AppsRequestProcessor { request_id: &ConnectionRequestId, params: AppsListParams, ) -> Result, 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, request_id: ConnectionRequestId, @@ -121,7 +129,9 @@ impl AppsRequestProcessor { environment_manager: Arc, mcp_manager: Arc, plugins_manager: Arc, + 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), diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 35c12d49c1..ed8495d602 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -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, 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, diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index b78c53a383..af8525873f 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -364,6 +364,7 @@ pub(crate) struct ThreadRequestProcessor { pub(super) log_db: Option, pub(super) background_tasks: TaskTracker, pub(super) skills_watcher: Arc, + pub(super) initial_config_warnings: Arc>, } /// Outcome of trying to satisfy a resume request from an already loaded thread. @@ -395,6 +396,7 @@ impl ThreadRequestProcessor { state_db: Option, log_db: Option, skills_watcher: Arc, + initial_config_warnings: Vec, ) -> 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, + initial_config_warnings: Arc>, ) -> 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(¬ification) { + 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 diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index 90d25ff593..598e0bde08 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -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); diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index bb074e457b..3ff64cd34e 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -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, diff --git a/codex-rs/app-server/tests/common/Cargo.toml b/codex-rs/app-server/tests/common/Cargo.toml index a1537208bb..8123346dc6 100644 --- a/codex-rs/app-server/tests/common/Cargo.toml +++ b/codex-rs/app-server/tests/common/Cargo.toml @@ -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", diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index fbc674806a..f9dee08a5b 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -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(response: JSONRPCResponse) -> anyhow::Result { let value = serde_json::to_value(response.result)?; diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index cbb8ff161e..e080ab0261 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -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, auto_env: Option, json_logs: JsonLogCapture, + codex_home: PathBuf, + _owned_codex_home: Option, } 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 { self.process.wait().await } pub async fn new(codex_home: &Path) -> anyhow::Result { - 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::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, ) -> anyhow::Result { - 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 { - 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 { - 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::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 { - 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::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::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 { - 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::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::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 { - 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 { + 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::(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 { + 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, + environment: TestAppServerEnvironment, + program: Option, + env_overrides: Vec<(String, Option)>, + args: Vec, +} + +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) -> 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 { + 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::>(); + let args = args.iter().map(String::as_str).collect::>(); + 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. diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index e49e841f65..4869748070 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -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 diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 64a0e62249..9cb1409d79 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -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 diff --git a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs index 2c30d05fde..2a6c8808b7 100644 --- a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs +++ b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs @@ -46,7 +46,11 @@ shell_snapshot = false async fn initialized_mcp(codex_home: &TempDir) -> Result { 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(); diff --git a/codex-rs/app-server/tests/suite/logging.rs b/codex-rs/app-server/tests/suite/logging.rs index 8d39a485d1..5628b62b6b 100644 --- a/codex-rs/app-server/tests/suite/logging.rs +++ b/codex-rs/app-server/tests/suite/logging.rs @@ -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 diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 3cd98e2e52..ccf46321e6 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -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 { diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 5fad04929f..35c21e4c68 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -8,7 +8,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::(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, diff --git a/codex-rs/app-server/tests/suite/v2/attestation.rs b/codex-rs/app-server/tests/suite/v2/attestation.rs index ea567a2d43..4499c7a6d2 100644 --- a/codex-rs/app-server/tests/suite/v2/attestation.rs +++ b/codex-rs/app-server/tests/suite/v2/attestation.rs @@ -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( diff --git a/codex-rs/app-server/tests/suite/v2/auto_env.rs b/codex-rs/app-server/tests/suite/v2/auto_env.rs index d4c88f48fe..e3f3c30755 100644 --- a/codex-rs/app-server/tests/suite/v2/auto_env.rs +++ b/codex-rs/app-server/tests/suite/v2/auto_env.rs @@ -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() ) ); diff --git a/codex-rs/app-server/tests/suite/v2/client_metadata.rs b/codex-rs/app-server/tests/suite/v2/client_metadata.rs index 6020c332e6..78807a055a 100644 --- a/codex-rs/app-server/tests/suite/v2/client_metadata.rs +++ b/codex-rs/app-server/tests/suite/v2/client_metadata.rs @@ -59,7 +59,10 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result /*supports_websockets*/ false, )?; - 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 thread_req = mcp @@ -155,7 +158,11 @@ async fn turn_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() -> /*git_info*/ None, )?; - 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 ThreadForkResponse { thread, .. } = @@ -239,7 +246,11 @@ async fn review_start_sends_parent_lineage_in_turn_metadata_for_thread_fork_v2() /*git_info*/ None, )?; - 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 ThreadForkResponse { thread, .. } = @@ -339,7 +350,11 @@ async fn turn_start_sends_nested_subagent_lineage_after_cold_thread_resume_v2() parent_thread_id, )?; - 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 resume_req = mcp @@ -434,7 +449,10 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2() /*supports_websockets*/ false, )?; - 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 thread_req = mcp @@ -559,7 +577,10 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body /*supports_websockets*/ true, )?; - 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 thread_req = mcp diff --git a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs index 31dd810fbd..9db8c8578b 100644 --- a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs +++ b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs @@ -28,7 +28,11 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); #[tokio::test] async fn list_collaboration_modes_returns_presets() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = 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??; diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index 7cad6c0388..edd704c9fb 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -42,7 +42,11 @@ async fn command_exec_without_streams_can_be_terminated() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = 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 process_id = "sleep-1".to_string(); @@ -92,7 +96,11 @@ async fn command_exec_without_process_id_keeps_buffered_compatibility() -> Resul let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = 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 command_request_id = mcp @@ -140,11 +148,12 @@ async fn command_exec_env_overrides_merge_with_server_environment_and_support_un let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[("COMMAND_EXEC_BASELINE", Some("server"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("COMMAND_EXEC_BASELINE", Some("server"))]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -198,7 +207,11 @@ async fn command_exec_accepts_permission_profile() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = 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 command_request_id = mcp @@ -249,7 +262,11 @@ async fn command_exec_permission_profile_starts_selected_network_proxy() -> Resu codex_home.path(), /*default_permissions*/ None, )?; - 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 command_request_id = mcp @@ -297,7 +314,11 @@ async fn command_exec_permission_profile_does_not_reuse_default_network_proxy() let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; insert_networked_permission_profile_config(codex_home.path(), Some("networked"))?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -355,7 +376,11 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu ":workspace_roots" = "write" "#, )?; - 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 command_request_id = mcp @@ -406,11 +431,12 @@ async fn command_exec_returns_error_when_local_environment_is_disabled() -> Resu let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -445,7 +471,11 @@ async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = 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 command_request_id = mcp @@ -483,7 +513,11 @@ async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = 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 command_request_id = mcp @@ -521,7 +555,11 @@ async fn command_exec_rejects_disable_output_cap_with_output_bytes_cap() -> Resu let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = 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 command_request_id = mcp @@ -559,7 +597,11 @@ async fn command_exec_rejects_negative_timeout_ms() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = 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 command_request_id = mcp @@ -597,7 +639,11 @@ async fn command_exec_without_process_id_rejects_streaming() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = 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 command_request_id = mcp @@ -635,7 +681,11 @@ async fn command_exec_non_streaming_respects_output_cap() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = 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 command_request_id = mcp @@ -682,7 +732,11 @@ async fn command_exec_streaming_does_not_buffer_output() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = 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 process_id = "stream-cap-1".to_string(); @@ -746,7 +800,11 @@ async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = 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 process_id = "pipe-1".to_string(); @@ -822,7 +880,11 @@ async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<( let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = 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 process_id = "tty-1".to_string(); @@ -893,7 +955,11 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = 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 process_id = "tty-size-1".to_string(); diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index bca34d360d..7f241d28d3 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -81,7 +81,11 @@ async fn auto_compaction_local_emits_started_and_completed_items() -> Result<()> COMPACT_PROMPT, )?; - 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 thread_id = start_thread(&mut mcp).await?; @@ -164,8 +168,12 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> 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 thread_id = start_thread(&mut mcp).await?; @@ -271,7 +279,11 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() - COMPACT_PROMPT, )?; - 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 thread_id = start_thread(&mut mcp).await?; @@ -321,7 +333,11 @@ async fn thread_compact_start_rejects_invalid_thread_id() -> Result<()> { COMPACT_PROMPT, )?; - 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 @@ -357,7 +373,11 @@ async fn thread_compact_start_rejects_unknown_thread_id() -> Result<()> { COMPACT_PROMPT, )?; - 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 diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 07b8ccc2e8..b1ef93d70f 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -55,7 +55,11 @@ async fn config_requirements_read_includes_allow_remote_control() -> Result<()> codex_home.path().join("requirements.toml"), "allow_remote_control = false\n", )?; - 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_config_requirements_read_request().await?; @@ -87,7 +91,11 @@ model_reasoning_effort = "medium" service_tier = "fast" "#, )?; - 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_config_requirements_read_request().await?; @@ -125,7 +133,11 @@ sandbox_mode = "workspace-write" let codex_home_path = codex_home.path().canonicalize()?; let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; - let mut mcp = 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 @@ -175,7 +187,11 @@ allowed_domains = ["example.com"] let codex_home_path = codex_home.path().canonicalize()?; let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; - let mut mcp = 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 @@ -246,7 +262,11 @@ forced_chatgpt_workspace_id = "{WORKSPACE_ID}" ), )?; - 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 @@ -285,7 +305,11 @@ forced_chatgpt_workspace_id = ["{WORKSPACE_ID_A}", "{WORKSPACE_ID_B}"] ), )?; - 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 @@ -327,7 +351,11 @@ location = { country = "US", city = "New York", timezone = "America/New_York" } "#, )?; - 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 @@ -371,7 +399,11 @@ web_search = true "#, )?; - 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 @@ -400,7 +432,7 @@ async fn config_read_includes_apps() -> Result<()> { r#" [apps._default] approvals_reviewer = "auto_review" -default_tools_approval_mode = "approve" +default_tools_approval_mode = "writes" [apps.app1] enabled = false @@ -412,7 +444,11 @@ default_tools_approval_mode = "prompt" let codex_home_path = codex_home.path().canonicalize()?; let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; - let mut mcp = 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 @@ -440,7 +476,7 @@ default_tools_approval_mode = "prompt" approvals_reviewer: Some(ApprovalsReviewer::AutoReview), destructive_enabled: true, open_world_enabled: true, - default_tools_approval_mode: Some(AppToolApproval::Approve), + default_tools_approval_mode: Some(AppToolApproval::Writes), }), apps: std::collections::HashMap::from([( "app1".to_string(), @@ -536,7 +572,11 @@ width = 320 "#, )?; - 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 @@ -583,7 +623,11 @@ model_reasoning_effort = "high" set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; let project_config = AbsolutePathBuf::try_from(project_config_dir)?; - let mut mcp = 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 @@ -653,14 +697,15 @@ writable_roots = [{}] let managed_path_str = managed_path.display().to_string(); - 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(&[( "CODEX_APP_SERVER_MANAGED_CONFIG_PATH", Some(&managed_path_str), - )], - ) - .await?; + )]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -749,7 +794,11 @@ model = "gpt-old" "#, )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -810,7 +859,11 @@ async fn config_value_write_updates_desktop_settings() -> Result<()> { let codex_home = temp_dir.path().canonicalize()?; write_config(&temp_dir, "")?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let write_id = mcp @@ -859,7 +912,11 @@ model = "gpt-old" "#, )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let write_id = mcp @@ -907,7 +964,11 @@ model = "gpt-old" "#, )?; - 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 write_id = mcp @@ -942,7 +1003,11 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { let codex_home = tmp_dir.path().canonicalize()?; write_config(&tmp_dir, "")?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let writable_root = test_tmp_path_buf(); @@ -1014,7 +1079,11 @@ model = "gpt-5.3-spark" "#, )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let batch_id = mcp @@ -1070,7 +1139,11 @@ async fn config_batch_write_updates_multiple_desktop_settings() -> Result<()> { let codex_home = tmp_dir.path().canonicalize()?; write_config(&tmp_dir, "")?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let batch_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index 648d60f3b5..ece66c8405 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -7,6 +7,7 @@ use app_test_support::to_response; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; @@ -18,6 +19,8 @@ use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_core::config::set_project_trust_level; +use codex_protocol::config_types::TrustLevel; use futures::SinkExt; use futures::StreamExt; use hmac::Hmac; @@ -104,6 +107,106 @@ async fn websocket_transport_routes_per_connection_handshake_and_responses() -> Ok(()) } +#[tokio::test] +async fn thread_start_routes_project_exec_policy_warning_to_requester() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let project = TempDir::new()?; + std::fs::create_dir(project.path().join(".git"))?; + let rules_dir = project.path().join(".codex/rules"); + std::fs::create_dir_all(&rules_dir)?; + let rules_path = rules_dir.join("broken.rules"); + std::fs::write(&rules_path, "prefix_rule(")?; + set_project_trust_level(codex_home.path(), project.path(), TrustLevel::Trusted)?; + + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + let mut requester = connect_websocket(bind_addr).await?; + let mut other_client = connect_websocket(bind_addr).await?; + + send_initialize_request(&mut requester, /*id*/ 1, "requester").await?; + read_response_for_id(&mut requester, /*id*/ 1).await?; + send_initialize_request(&mut other_client, /*id*/ 2, "other_client").await?; + read_response_for_id(&mut other_client, /*id*/ 2).await?; + + send_request( + &mut requester, + "thread/start", + /*id*/ 3, + Some(serde_json::to_value(ThreadStartParams { + cwd: Some(project.path().display().to_string()), + model: Some("mock-model".to_string()), + ..Default::default() + })?), + ) + .await?; + + let target_id = RequestId::Integer(3); + let warning_summary = "Error parsing rules; custom rules not applied."; + let is_exec_policy_warning = |notification: &JSONRPCNotification| { + notification.method == "configWarning" + && notification + .params + .as_ref() + .and_then(|params| params.get("summary")) + .and_then(serde_json::Value::as_str) + == Some(warning_summary) + }; + let mut response = None; + let mut warning = None; + while response.is_none() || warning.is_none() { + match read_jsonrpc_message(&mut requester).await? { + JSONRPCMessage::Response(candidate) if candidate.id == target_id => { + response = Some(candidate); + } + JSONRPCMessage::Notification(candidate) if is_exec_policy_warning(&candidate) => { + warning = Some(candidate); + } + _ => {} + } + } + + let _: ThreadStartResponse = to_response(response.context("missing thread/start response")?)?; + let warning: ConfigWarningNotification = serde_json::from_value( + warning + .context("missing exec-policy configWarning")? + .params + .context("configWarning should include params")?, + )?; + assert_eq!( + warning + .path + .as_deref() + .map(Path::new) + .and_then(Path::file_name), + Some(std::ffi::OsStr::new("broken.rules")) + ); + + match timeout(Duration::from_millis(250), async { + loop { + let message = read_jsonrpc_message(&mut other_client).await?; + if let JSONRPCMessage::Notification(notification) = message + && is_exec_policy_warning(¬ification) + { + return Ok::<_, anyhow::Error>(notification); + } + } + }) + .await + { + Ok(Ok(_)) => bail!("exec-policy configWarning leaked to another connection"), + Ok(Err(err)) => return Err(err), + Err(_) => {} + } + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + #[tokio::test] async fn websocket_transport_serves_health_endpoints_on_same_listener() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; diff --git a/codex-rs/app-server/tests/suite/v2/current_time.rs b/codex-rs/app-server/tests/suite/v2/current_time.rs index d4a3a58e40..a8a05f7fe1 100644 --- a/codex-rs/app-server/tests/suite/v2/current_time.rs +++ b/codex-rs/app-server/tests/suite/v2/current_time.rs @@ -40,7 +40,10 @@ async fn current_time_read_round_trip_adds_reminder_to_model_input() -> Result<( let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut app_server = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; let thread_request_id = app_server diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index 4183bd785f..58602b4d08 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -57,7 +57,11 @@ async fn thread_start_normalizes_legacy_dynamic_tools_into_model_request() -> Re let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - 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 visible_schema = json!({ @@ -171,7 +175,10 @@ async fn thread_start_rejects_hidden_dynamic_tools_without_namespace() -> Result let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 dynamic_tool = DynamicToolSpec::Function(DynamicToolFunctionSpec { @@ -210,7 +217,11 @@ async fn thread_start_rejects_invalid_dynamic_tool_inputs() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - 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??; for (dynamic_tools, expected_error) in [ @@ -352,7 +363,10 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 input_schema = json!({ @@ -579,7 +593,10 @@ async fn start_function_dynamic_tool_call(call_id: &str) -> Result Result<()> { Ok::<_, anyhow::Error>(()) }); let codex_home = TempDir::new()?; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(RPC_TIMEOUT, app_server.initialize()).await??; let request_id = app_server diff --git a/codex-rs/app-server/tests/suite/v2/environment_info.rs b/codex-rs/app-server/tests/suite/v2/environment_info.rs index 8b0e82518f..765144968b 100644 --- a/codex-rs/app-server/tests/suite/v2/environment_info.rs +++ b/codex-rs/app-server/tests/suite/v2/environment_info.rs @@ -40,7 +40,11 @@ async fn environment_info_returns_remote_environment_info() -> Result<()> { }); let codex_home = TempDir::new()?; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(RPC_TIMEOUT, app_server.initialize()).await??; add_environment( &mut app_server, @@ -88,7 +92,11 @@ async fn environment_info_accepts_missing_cwd() -> Result<()> { }); let codex_home = TempDir::new()?; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(RPC_TIMEOUT, app_server.initialize()).await??; add_environment( &mut app_server, @@ -125,7 +133,11 @@ async fn environment_info_accepts_missing_cwd() -> Result<()> { #[tokio::test] async fn environment_info_rejects_unknown_environment() -> Result<()> { let codex_home = TempDir::new()?; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(RPC_TIMEOUT, app_server.initialize()).await??; let request_id = app_server @@ -158,7 +170,11 @@ async fn environment_info_reports_connection_failure() -> Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; let exec_server_url = format!("ws://{}", listener.local_addr()?); let codex_home = TempDir::new()?; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(RPC_TIMEOUT, app_server.initialize()).await??; add_environment(&mut app_server, &exec_server_url, Some(50)).await?; diff --git a/codex-rs/app-server/tests/suite/v2/executor_mcp.rs b/codex-rs/app-server/tests/suite/v2/executor_mcp.rs index aa650c3ecf..b240308a08 100644 --- a/codex-rs/app-server/tests/suite/v2/executor_mcp.rs +++ b/codex-rs/app-server/tests/suite/v2/executor_mcp.rs @@ -176,7 +176,11 @@ HTTP_PROXY = {http_proxy} }))?, )?; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; let selected_thread = start_thread( diff --git a/codex-rs/app-server/tests/suite/v2/executor_skills.rs b/codex-rs/app-server/tests/suite/v2/executor_skills.rs index a15e285d31..42120ccb39 100644 --- a/codex-rs/app-server/tests/suite/v2/executor_skills.rs +++ b/codex-rs/app-server/tests/suite/v2/executor_skills.rs @@ -81,7 +81,11 @@ stream_max_retries = 0 ), )?; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(READ_TIMEOUT, app_server.initialize()).await??; let request_id = app_server diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index d9d3469936..8a33a33d2c 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -30,7 +30,11 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::test] async fn mock_experimental_method_requires_experimental_api_capability() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let init = mcp .initialize_with_capabilities( @@ -62,7 +66,11 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu #[tokio::test] async fn realtime_conversation_start_requires_experimental_api_capability() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let init = mcp .initialize_with_capabilities( @@ -82,6 +90,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R let request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -108,7 +117,11 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R #[tokio::test] async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let init = mcp .initialize_with_capabilities( @@ -143,7 +156,11 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result #[tokio::test] async fn thread_settings_update_requires_experimental_api_capability() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let init = mcp .initialize_with_capabilities( @@ -178,7 +195,11 @@ async fn thread_settings_update_requires_experimental_api_capability() -> Result #[tokio::test] async fn realtime_webrtc_start_requires_experimental_api_capability() -> 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?; let init = mcp .initialize_with_capabilities( @@ -198,6 +219,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< let request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -229,7 +251,11 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let init = mcp .initialize_with_capabilities( default_client_info(), @@ -268,7 +294,11 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let init = mcp .initialize_with_capabilities( default_client_info(), @@ -306,7 +336,11 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let init = mcp .initialize_with_capabilities( default_client_info(), diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 797736b7af..6b7f3aea2c 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -50,7 +50,11 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu )) .build() .await?; - 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??; @@ -134,7 +138,12 @@ async fn experimental_feature_list_marks_apps_and_plugins_disabled_by_workspace_ .mount(&server) .await; - 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 @@ -195,7 +204,12 @@ memories = true "#, )?; - 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 thread_start_id = mcp @@ -229,7 +243,11 @@ memories = true #[tokio::test] async fn experimental_feature_list_rejects_unknown_thread_id() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + 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 @@ -264,7 +282,11 @@ async fn experimental_feature_enablement_set_applies_to_global_and_thread_config let project_cwd = codex_home.path().join("project"); std::fs::create_dir_all(&project_cwd)?; - let mut mcp = 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 actual = set_experimental_feature_enablement( @@ -301,7 +323,11 @@ async fn experimental_feature_enablement_set_does_not_override_user_config() -> codex_home.path().join("config.toml"), "[features]\nmemories = false\n", )?; - 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 actual = set_experimental_feature_enablement( @@ -332,7 +358,11 @@ async fn experimental_feature_enablement_set_does_not_override_user_config() -> #[tokio::test] async fn experimental_feature_enablement_set_only_updates_named_features() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = 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??; set_experimental_feature_enablement( @@ -407,7 +437,11 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re #[tokio::test] async fn experimental_feature_enablement_set_allows_remote_control() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = 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 remote_control_enabled = false; let enablement = BTreeMap::from([("remote_control".to_string(), remote_control_enabled)]); @@ -425,7 +459,11 @@ async fn experimental_feature_enablement_set_allows_remote_control() -> Result<( #[tokio::test] async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = 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??; set_experimental_feature_enablement( @@ -458,7 +496,11 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> #[tokio::test] async fn experimental_feature_enablement_set_ignores_invalid_features() -> 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??; let actual = set_experimental_feature_enablement( diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index 3d4b09579e..6bef968941 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -58,14 +58,15 @@ async fn external_agent_config_import_sends_completion_notification_for_sync_onl let sqlite_home = TempDir::new()?; let home_dir = codex_home.path().display().to_string(); let sqlite_home_dir = sqlite_home.path().display().to_string(); - 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(&[ ("HOME", Some(home_dir.as_str())), ("CODEX_SQLITE_HOME", Some(sqlite_home_dir.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -189,17 +190,18 @@ async fn external_agent_config_import_reports_failed_sync_import_in_completion() let home_dir = codex_home.path().display().to_string(); let analytics_capture_file = codex_home.path().join("analytics-events.jsonl"); let analytics_capture_file = analytics_capture_file.display().to_string(); - 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(&[ ("HOME", Some(home_dir.as_str())), ( "CODEX_ANALYTICS_EVENTS_CAPTURE_FILE", Some(analytics_capture_file.as_str()), ), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -335,9 +337,12 @@ async fn external_agent_config_import_completed_tracks_analytics_event() -> Resu external_agent_home(codex_home.path()).join("projects/repo/missing.jsonl"); let project_root = codex_home.path().join("repo"); let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -458,9 +463,12 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl )?; let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -551,9 +559,12 @@ async fn external_agent_config_import_sends_completion_notification_after_pendin )?; let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -633,9 +644,12 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { )?; let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -845,9 +859,12 @@ required = true )?; let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -931,9 +948,12 @@ async fn external_agent_config_import_accepts_detected_session_payload_after_res )?; let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1022,9 +1042,12 @@ async fn external_agent_config_import_skips_already_imported_session_versions() )?; let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1115,9 +1138,12 @@ async fn external_agent_config_import_returns_before_background_session_import_f std::fs::write(&session_path, &session_contents)?; let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1291,9 +1317,12 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_ )?; let home_dir = codex_home.path().display().to_string(); - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/fs.rs b/codex-rs/app-server/tests/suite/v2/fs.rs index 1f4a4dec38..295b3b2611 100644 --- a/codex-rs/app-server/tests/suite/v2/fs.rs +++ b/codex-rs/app-server/tests/suite/v2/fs.rs @@ -37,7 +37,11 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); const OPTIONAL_FS_CHANGE_TIMEOUT: Duration = Duration::from_secs(2); async fn initialized_mcp(codex_home: &TempDir) -> Result { - let mut mcp = 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) } @@ -124,11 +128,12 @@ async fn fs_methods_return_error_when_local_environment_is_disabled() -> Result< let codex_home = TempDir::new()?; let absolute_file = codex_home.path().join("absolute.txt"); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index 510ed324bc..71cc2dbaa8 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -135,7 +135,11 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> { let cwd = TempDir::new()?; write_user_hook_config(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_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -211,7 +215,11 @@ async fn hooks_list_shows_discovered_plugin_hook() -> 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_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -299,7 +307,11 @@ async fn hooks_list_warms_plugin_capabilities_for_thread_start() -> 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_TIMEOUT, mcp.initialize()).await??; let hooks_list_id = mcp @@ -348,7 +360,11 @@ async fn hooks_list_shows_plugin_hook_load_warnings() -> Result<()> { let cwd = TempDir::new()?; write_plugin_hook_config(codex_home.path(), "{ not-json")?; - let mut mcp = 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 @@ -404,7 +420,11 @@ timeout = 5 )?; set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; - 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 +506,11 @@ async fn hooks_list_uses_root_repo_hooks_for_linked_worktrees() -> Result<()> { write_project_hook_config(&worktree_root.join(".codex"), "echo worktree hook")?; set_project_trust_level(codex_home.path(), &repo_root, TrustLevel::Trusted)?; - let mut mcp = 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 list_id = mcp @@ -556,7 +580,11 @@ async fn config_batch_write_toggles_user_hook() -> Result<()> { let cwd = TempDir::new()?; write_user_hook_config(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_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -707,7 +735,11 @@ command = "python3 {hook_script_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_TIMEOUT, mcp.initialize()).await??; let hook_list_id = mcp @@ -957,7 +989,11 @@ command = "python3 {hook_script_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_TIMEOUT, mcp.initialize()).await??; let hook_list_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index 8c1643e147..0c3799ca11 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -7,6 +7,7 @@ use app_test_support::ChatGptAuthFixture; use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::ImageGenerationItem; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -87,8 +88,12 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul 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??; start_image_generation_turn(&mut mcp).await?; @@ -103,13 +108,13 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul ) .await??; - let ThreadItem::ImageGeneration { + let ThreadItem::ImageGeneration(ImageGenerationItem { status, revised_prompt, result, saved_path: Some(saved_path), .. - } = completed.item + }) = completed.item else { panic!("expected completed image generation item with saved path"); }; @@ -185,8 +190,12 @@ async fn standalone_image_generation_failure_emits_terminal_item() -> Result<()> ChatGptAuthFixture::new("access-chatgpt"), 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??; start_image_generation_turn(&mut mcp).await?; @@ -197,13 +206,13 @@ async fn standalone_image_generation_failure_emits_terminal_item() -> Result<()> .await??; assert_eq!( completed.item, - ThreadItem::ImageGeneration { + ThreadItem::ImageGeneration(ImageGenerationItem { id: call_id.to_string(), status: "failed".to_string(), revised_prompt: Some("paint a blue whale".to_string()), result: String::new(), saved_path: None, - } + }) ); timeout( @@ -306,8 +315,12 @@ async fn standalone_image_generation_is_exposed_in_code_mode_only() -> 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??; start_image_generation_turn(&mut mcp).await?; timeout( @@ -369,8 +382,12 @@ generatedImage(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??; start_image_generation_turn(&mut mcp).await?; timeout( @@ -449,8 +466,12 @@ async fn run_image_edit_test( 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??; start_turn(&mut mcp, input).await?; timeout( @@ -517,7 +538,7 @@ async fn wait_for_image_generation_completed( .params .context("item/completed notification should include params")?, )?; - if matches!(&completed.item, ThreadItem::ImageGeneration { .. }) { + if matches!(&completed.item, ThreadItem::ImageGeneration(_)) { return Ok(completed); } } diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 69253e760f..aba3f56078 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -33,7 +33,11 @@ async fn initialize_uses_client_info_name_as_originator() -> Result<()> { let codex_home = TempDir::new()?; let expected_codex_home = AbsolutePathBuf::try_from(codex_home.path().canonicalize()?)?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let message = timeout( DEFAULT_READ_TIMEOUT, @@ -68,7 +72,11 @@ async fn initialize_probe_does_not_override_originator() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let message = timeout( DEFAULT_READ_TIMEOUT, @@ -95,7 +103,11 @@ async fn initialize_codex_backend_does_not_override_originator() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; let message = timeout( DEFAULT_READ_TIMEOUT, @@ -123,14 +135,15 @@ async fn initialize_respects_originator_override_env_var() -> Result<()> { let codex_home = TempDir::new()?; let expected_codex_home = AbsolutePathBuf::try_from(codex_home.path().canonicalize()?)?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[( + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[( "CODEX_INTERNAL_ORIGINATOR_OVERRIDE", Some("codex_originator_via_env_var"), - )], - ) - .await?; + )]) + .build() + .await?; let message = timeout( DEFAULT_READ_TIMEOUT, @@ -165,11 +178,12 @@ async fn initialize_rejects_invalid_client_name() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", None)], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", None)]) + .build() + .await?; let message = timeout( DEFAULT_READ_TIMEOUT, @@ -200,7 +214,10 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; let message = timeout( DEFAULT_READ_TIMEOUT, @@ -283,7 +300,10 @@ async fn turn_start_notify_payload_includes_initialize_client_name() -> Result<( ), )?; - 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_with_client_info(ClientInfo { diff --git a/codex-rs/app-server/tests/suite/v2/marketplace_add.rs b/codex-rs/app-server/tests/suite/v2/marketplace_add.rs index 7dc9f745af..89a9301255 100644 --- a/codex-rs/app-server/tests/suite/v2/marketplace_add.rs +++ b/codex-rs/app-server/tests/suite/v2/marketplace_add.rs @@ -28,7 +28,11 @@ async fn marketplace_add_local_directory_source() -> Result<()> { r#"{"name":"sample"}"#, )?; std::fs::write(source.join("plugins/sample/marker.txt"), "local ref")?; - let mut mcp = 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 diff --git a/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs b/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs index c6808b8271..3f84180ae1 100644 --- a/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs +++ b/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs @@ -53,7 +53,11 @@ async fn marketplace_remove_deletes_config_and_installed_root() -> Result<()> { write_installed_marketplace(codex_home.path(), "debug")?; let installed_root = marketplace_install_root(codex_home.path()).join("debug"); - let mut mcp = 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 @@ -91,7 +95,11 @@ async fn marketplace_remove_deletes_config_and_installed_root() -> Result<()> { async fn marketplace_remove_rejects_unknown_marketplace() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = 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 diff --git a/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs b/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs index 5ed1f2c6f5..6e8f300d0d 100644 --- a/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs +++ b/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs @@ -169,7 +169,11 @@ async fn marketplace_upgrade_all_configured_git_marketplaces() -> Result<()> { )?; disable_plugin_startup_tasks(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_TIMEOUT, mcp.initialize()).await??; let debug_root = expected_installed_root(codex_home.path(), "debug")?; @@ -223,7 +227,11 @@ async fn marketplace_upgrade_named_marketplace_only() -> Result<()> { )?; disable_plugin_startup_tasks(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_TIMEOUT, mcp.initialize()).await??; let tools_root = expected_installed_root(codex_home.path(), "tools")?; @@ -264,7 +272,11 @@ async fn marketplace_upgrade_returns_empty_roots_when_already_up_to_date() -> Re )?; disable_plugin_startup_tasks(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_TIMEOUT, mcp.initialize()).await??; let first_response = send_marketplace_upgrade(&mut mcp, Some("debug")).await?; assert!(first_response.errors.is_empty()); @@ -292,7 +304,11 @@ async fn marketplace_upgrade_rejects_unknown_or_non_git_marketplace() -> Result< &configured_local_marketplace_update(&local_source.path().display().to_string()), )?; - 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??; for marketplace_name in ["missing", "local-only"] { diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index 1bdf06161f..d973835a16 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -552,7 +552,11 @@ apps = true 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_READ_TIMEOUT, mcp.initialize()).await??; let read_request_id = mcp @@ -692,7 +696,11 @@ stream_max_retries = 0 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_READ_TIMEOUT, mcp.initialize()).await??; Ok((codex_home, mcp)) } diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index fd11b44997..c6f1fc03a9 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -422,7 +422,10 @@ impl ElicitationRoundTripFixture { 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_READ_TIMEOUT, mcp.initialize_with_capabilities( diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs index 2c34684ada..1036847e43 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs @@ -67,7 +67,11 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - 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 @@ -132,7 +136,11 @@ async fn mcp_server_status_list_uses_thread_project_local_config() -> Result<()> std::fs::create_dir_all(workspace.path().join(".git"))?; set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp @@ -336,7 +344,11 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - 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 @@ -401,7 +413,11 @@ url = "{underscore_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - 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 diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index 1f3289e439..cf15e7031a 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -107,7 +107,10 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - 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 thread_start_id = mcp @@ -174,7 +177,11 @@ url = "{mcp_server_url}/mcp" #[tokio::test] async fn mcp_server_tool_call_returns_error_for_unknown_thread() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = 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 @@ -225,7 +232,10 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - 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 thread_start_id = mcp @@ -336,7 +346,11 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - 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 add_environment_id = mcp .send_raw_request( @@ -474,7 +488,10 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - 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 thread_start_id = mcp @@ -594,7 +611,10 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - 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 thread_start_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/memory_reset.rs b/codex-rs/app-server/tests/suite/v2/memory_reset.rs index 07e16a3ba6..ab8445378d 100644 --- a/codex-rs/app-server/tests/suite/v2/memory_reset.rs +++ b/codex-rs/app-server/tests/suite/v2/memory_reset.rs @@ -36,7 +36,11 @@ async fn memory_reset_clears_memory_files_and_rows_preserves_threads() -> Result let thread_id = seed_stage1_output(&state_db, codex_home.path()).await?; - let mut mcp = 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 diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index bdb03271d0..3d55079dc6 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -60,6 +60,7 @@ mod review; mod safety_check_downgrade; #[cfg(not(target_os = "windows"))] mod selected_capability_stack; +mod selected_environment; mod skills_list; mod sleep; mod thread_archive; diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index d2538629c8..92706a817f 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -96,7 +96,11 @@ fn expected_visible_models() -> Vec { async fn list_models_returns_all_models_with_large_limit() -> Result<()> { let codex_home = TempDir::new()?; write_models_cache(codex_home.path())?; - let mut mcp = 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??; @@ -130,7 +134,11 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { async fn list_models_includes_hidden_models() -> Result<()> { let codex_home = TempDir::new()?; write_models_cache(codex_home.path())?; - let mut mcp = 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??; @@ -216,8 +224,12 @@ openai_base_url = "{server_uri}/v1" 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_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -273,7 +285,11 @@ openai_base_url = "{server_uri}/v1" async fn list_models_pagination_works() -> Result<()> { let codex_home = TempDir::new()?; write_models_cache(codex_home.path())?; - let mut mcp = 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??; @@ -322,7 +338,11 @@ async fn list_models_pagination_works() -> Result<()> { async fn list_models_rejects_invalid_cursor() -> Result<()> { let codex_home = TempDir::new()?; write_models_cache(codex_home.path())?; - let mut mcp = 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??; diff --git a/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs b/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs index 4143e825ea..58eb5ed6e7 100644 --- a/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs +++ b/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs @@ -16,7 +16,11 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); #[tokio::test] async fn read_default_provider_capabilities() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = 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 @@ -46,7 +50,11 @@ async fn read_amazon_bedrock_provider_capabilities() -> Result<()> { r#"model_provider = "amazon-bedrock" "#, )?; - 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 diff --git a/codex-rs/app-server/tests/suite/v2/output_schema.rs b/codex-rs/app-server/tests/suite/v2/output_schema.rs index 4c2a3671b1..668074bd1f 100644 --- a/codex-rs/app-server/tests/suite/v2/output_schema.rs +++ b/codex-rs/app-server/tests/suite/v2/output_schema.rs @@ -32,7 +32,10 @@ async fn turn_start_accepts_output_schema_v2() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_req = mcp @@ -115,7 +118,10 @@ async fn turn_start_output_schema_is_per_turn_v2() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_req = mcp diff --git a/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs b/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs index 5d02cafa0a..d291dda172 100644 --- a/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs +++ b/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs @@ -41,7 +41,11 @@ description = "Inspect without writes." "#, )?; - 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 @@ -113,7 +117,11 @@ description = "Project-scoped profile." )?; set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; - 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_id = mcp @@ -190,7 +198,11 @@ description = "Project-scoped profile." )?; set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; - 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 diff --git a/codex-rs/app-server/tests/suite/v2/plan_item.rs b/codex-rs/app-server/tests/suite/v2/plan_item.rs index 0f5f4cc8b3..9717d20bee 100644 --- a/codex-rs/app-server/tests/suite/v2/plan_item.rs +++ b/codex-rs/app-server/tests/suite/v2/plan_item.rs @@ -53,7 +53,10 @@ async fn plan_mode_uses_proposed_plan_block_for_plan_item() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 turn = start_plan_mode_turn(&mut mcp).await?; @@ -111,7 +114,10 @@ async fn plan_mode_without_proposed_plan_does_not_emit_plan_item() -> Result<()> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 _turn = start_plan_mode_turn(&mut mcp).await?; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 975338e11d..9d0a7bcff3 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -72,7 +72,11 @@ const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str = #[tokio::test] async fn plugin_install_rejects_relative_marketplace_paths() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = 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 @@ -99,7 +103,11 @@ async fn plugin_install_rejects_relative_marketplace_paths() -> Result<()> { #[tokio::test] async fn plugin_install_rejects_missing_install_source() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = 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 @@ -128,7 +136,11 @@ async fn plugin_install_rejects_missing_install_source() -> Result<()> { #[tokio::test] async fn plugin_install_rejects_multiple_install_sources() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = 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 @@ -165,7 +177,11 @@ async fn plugin_install_rejects_remote_marketplace_when_plugins_are_disabled() - plugins = 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_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -231,11 +247,12 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()> ) .await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -318,11 +335,12 @@ async fn plugin_install_uses_remote_apps_needing_auth_response() -> Result<()> { mount_empty_remote_installed_plugins(&server).await; mount_remote_plugin_install_with_apps_needing_auth(&server, REMOTE_PLUGIN_ID, &["alpha"]).await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -370,7 +388,11 @@ async fn plugin_install_rejects_missing_remote_bundle_url() -> Result<()> { .await; mount_empty_remote_installed_plugins(&server).await; - 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 = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -411,7 +433,11 @@ async fn plugin_install_rejects_plain_http_remote_bundle_url() -> Result<()> { mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; mount_empty_remote_installed_plugins(&server).await; - let mut mcp = 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 = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -457,7 +483,11 @@ async fn plugin_install_rejects_invalid_remote_release_version() -> Result<()> { .await; mount_empty_remote_installed_plugins(&server).await; - 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 = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -489,7 +519,11 @@ async fn plugin_install_rejects_invalid_remote_release_version() -> Result<()> { async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> { let codex_home = TempDir::new()?; write_remote_plugin_catalog_config(codex_home.path(), "https://example.invalid/backend-api/")?; - let mut mcp = 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 @@ -519,7 +553,11 @@ async fn plugin_install_tracks_analytics_when_remote_detail_fetch_fails() -> Res mount_empty_remote_installed_plugins(&server).await; mount_backend_analytics_events(&server).await; - 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 = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -570,11 +608,12 @@ async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download( .await; mount_empty_remote_installed_plugins(&server).await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -623,7 +662,11 @@ async fn plugin_install_rejects_remote_plugin_not_available() -> Result<()> { .await; mount_empty_remote_installed_plugins(&server).await; - 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 = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -686,7 +729,11 @@ async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Resul .mount(&server) .await; - 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 @@ -715,7 +762,11 @@ async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Resul #[tokio::test] async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = 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 @@ -756,7 +807,11 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = 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 @@ -805,8 +860,12 @@ async fn plugin_install_returns_invalid_request_for_disallowed_product_plugin() let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = - TestAppServer::new_with_args(codex_home.path(), &["--session-source", "atlas"]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_args(&["--session-source", "atlas"]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -855,7 +914,11 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> { let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = 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 @@ -921,7 +984,11 @@ async fn plugin_install_failure_tracks_analytics_event() -> Result<()> { let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - 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 @@ -972,11 +1039,12 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> { mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await; mount_backend_analytics_events(&server).await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -1023,11 +1091,12 @@ async fn plugin_install_preserves_status_when_remote_bundle_error_body_is_too_la mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await; mount_backend_analytics_events(&server).await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -1151,7 +1220,11 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> { let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = 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 directory_requests_before_install = server_control.directory_request_count(); @@ -1238,7 +1311,11 @@ async fn plugin_install_skips_mcp_oauth_for_chatgpt_dual_surface_plugin() -> Res let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - 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 @@ -1298,7 +1375,11 @@ async fn plugin_install_starts_mcp_oauth_with_formerly_disallowed_plugin_app() - let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - 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 @@ -1395,7 +1476,11 @@ async fn plugin_install_starts_mcp_oauth_through_protected_resource_metadata() - let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - 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 @@ -1458,11 +1543,12 @@ connectors = true let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[("OPENAI_API_KEY", Some("test-api-key"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", Some("test-api-key"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1500,11 +1586,12 @@ async fn plugin_install_starts_remote_mcp_oauth_for_install_response_only_app() mount_empty_remote_installed_plugins(&server).await; mount_remote_plugin_install_with_apps_needing_auth(&server, REMOTE_PLUGIN_ID, &["alpha"]).await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -1552,11 +1639,12 @@ async fn plugin_install_skips_remote_mcp_oauth_for_bundled_same_name_app() -> Re mount_empty_remote_installed_plugins(&server).await; mount_remote_plugin_install_with_apps_needing_auth(&server, REMOTE_PLUGIN_ID, &["alpha"]).await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -1634,7 +1722,11 @@ async fn plugin_install_includes_formerly_disallowed_apps_needing_auth() -> Resu let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - 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 directory_requests_before_install = warm_app_directory_cache(&mut mcp, &server_control, "Alpha").await?; @@ -1717,7 +1809,11 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = 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 diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index ae03b3a1a9..e42240ec65 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -9,6 +9,7 @@ use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInstallPolicySource; use codex_app_server_protocol::PluginInstalledParams; use codex_app_server_protocol::PluginInstalledResponse; use codex_app_server_protocol::PluginListMarketplaceKind; @@ -100,14 +101,15 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul std::fs::write(marketplace_path.as_path(), "{not json")?; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("HOME", Some(home.as_str())), ("USERPROFILE", Some(home.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -165,7 +167,11 @@ enabled = true "#, )?; - 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 @@ -196,6 +202,12 @@ enabled = true ] ); assert_eq!(response.marketplace_load_errors, Vec::new()); + assert!( + response.marketplaces[0] + .plugins + .iter() + .all(|plugin| plugin.install_policy_source.is_none()) + ); Ok(()) } @@ -250,7 +262,11 @@ enabled = true .await; mount_empty_user_installed_plugins(&server).await; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; let request_id = app_server @@ -289,11 +305,17 @@ enabled = true remote_marketplace .plugins .iter() - .map(|plugin| plugin.id.clone()) + .map(|plugin| (plugin.id.clone(), plugin.install_policy_source)) .collect::>(), vec![ - "linear@openai-curated-remote".to_string(), - "remote-only@openai-curated-remote".to_string(), + ( + "linear@openai-curated-remote".to_string(), + Some(PluginInstallPolicySource::WorkspaceSetting), + ), + ( + "remote-only@openai-curated-remote".to_string(), + Some(PluginInstallPolicySource::WorkspaceSetting), + ), ] ); assert_eq!(response.marketplace_load_errors, Vec::new()); @@ -314,7 +336,11 @@ enabled = true "#, )?; - 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 @@ -339,7 +365,11 @@ enabled = true #[tokio::test] async fn plugin_list_rejects_relative_cwds() -> 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??; let request_id = mcp @@ -416,14 +446,15 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ std::fs::write(invalid_marketplace_path.as_path(), "{not json")?; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("HOME", Some(home.as_str())), ("USERPROFILE", Some(home.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -462,6 +493,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ installed: false, enabled: false, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, interface: None, @@ -534,14 +566,16 @@ async fn plugin_list_returns_empty_when_workspace_codex_plugins_disabled() -> Re .await; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = TestAppServer::new_without_managed_config_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .with_env_overrides(&[ ("HOME", Some(home.as_str())), ("USERPROFILE", Some(home.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -625,14 +659,16 @@ async fn plugin_list_reuses_cached_workspace_codex_plugins_setting() -> Result<( .await; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = TestAppServer::new_without_managed_config_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .with_env_overrides(&[ ("HOME", Some(home.as_str())), ("USERPROFILE", Some(home.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; for _ in 0..2 { @@ -710,14 +746,15 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab )?; let home = codex_home.path().to_string_lossy().into_owned(); - 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(&[ ("HOME", Some(home.as_str())), ("USERPROFILE", Some(home.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -754,6 +791,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab installed: false, enabled: false, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(codex_app_server_protocol::PluginInterface { @@ -794,6 +832,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab installed: false, enabled: false, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, interface: None, @@ -827,14 +866,15 @@ async fn plugin_list_accepts_omitted_cwds() -> Result<()> { }"#, )?; let home = codex_home.path().to_string_lossy().into_owned(); - 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(&[ ("HOME", Some(home.as_str())), ("USERPROFILE", Some(home.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -887,7 +927,11 @@ async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<( &AbsolutePathBuf::try_from(plugin_root)?, )?; - 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 @@ -979,7 +1023,11 @@ enabled = 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_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1126,14 +1174,15 @@ enabled = false let workspace_default = TempDir::new()?; let home = codex_home.path().to_string_lossy().into_owned(); - 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(&[ ("HOME", Some(home.as_str())), ("USERPROFILE", Some(home.as_str())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1220,7 +1269,11 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res }"##, )?; - 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 @@ -1333,7 +1386,11 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> 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_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1421,7 +1478,11 @@ enabled = true "#, )?; - 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 @@ -1528,11 +1589,13 @@ async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> let installed_path = codex_home .path() .join("plugins/cache/openai-curated-remote/linear/1.2.3"); - let mut mcp = TestAppServer::new_with_env_and_plugin_startup_tasks( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_plugin_startup_tasks() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; wait_for_path_exists(&installed_path.join(".codex-plugin/plugin.json")).await?; @@ -1607,11 +1670,12 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() .path() .join("plugins/cache/openai-curated-remote/stale"); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1683,6 +1747,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", + "installation_policy_source": "IMPLICIT_CANONICAL_APP", "authentication_policy": "ON_USE", "status": "ENABLED", "release": { @@ -1722,6 +1787,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", + "installation_policy_source": "WORKSPACE_SETTING", "authentication_policy": "ON_USE", "status": "ENABLED", "release": { @@ -1794,7 +1860,11 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - .mount(&server) .await; - 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 @@ -1845,6 +1915,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - ); assert_eq!(remote_marketplace.plugins[0].installed, true); assert_eq!(remote_marketplace.plugins[0].enabled, true); + assert_eq!( + remote_marketplace.plugins[0].install_policy_source, + Some(PluginInstallPolicySource::ImplicitCanonicalApp) + ); assert_eq!( remote_marketplace.plugins[0].availability, codex_app_server_protocol::PluginAvailability::Available @@ -1880,6 +1954,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - let cached_catalog: serde_json::Value = serde_json::from_slice(&std::fs::read(&cache_files[0])?)?; assert_eq!(cached_catalog["schema_version"], serde_json::json!(1)); + assert_eq!( + cached_catalog["plugins"][0]["installation_policy_source"], + serde_json::json!("IMPLICIT_CANONICAL_APP") + ); assert_eq!( cached_catalog["plugins"][0]["release"]["interface"]["default_prompts"], serde_json::json!(["Create a Linear issue", "Review my Linear projects"]) @@ -1945,7 +2023,11 @@ async fn plugin_list_uses_cached_global_remote_catalog_and_refreshes_it() -> Res .await; mount_empty_user_installed_plugins(&server).await; - 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 @@ -2061,7 +2143,11 @@ async fn plugin_list_includes_openai_curated_remote_collection_when_remote_plugi .await; mount_empty_user_installed_plugins(&server).await; - 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 @@ -2151,7 +2237,11 @@ async fn plugin_list_propagates_openai_curated_remote_collection_errors_when_rem .await; mount_empty_user_installed_plugins(&server).await; - 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 @@ -2191,7 +2281,11 @@ async fn plugin_list_skips_openai_curated_remote_collection_for_api_auth_when_re 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 @@ -2230,7 +2324,11 @@ async fn plugin_list_includes_api_curated_marketplace_for_api_auth_when_remote_p 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 @@ -2285,7 +2383,11 @@ async fn plugin_list_does_not_query_openai_curated_remote_collection_by_default( 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 @@ -2338,7 +2440,11 @@ async fn plugin_list_vertical_kind_noops_when_remote_plugin_enabled() -> 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 @@ -2392,7 +2498,11 @@ async fn plugin_list_does_not_append_global_remote_when_marketplace_kinds_are_ex 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 @@ -2471,7 +2581,11 @@ plugin_sharing = true mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; mount_empty_user_installed_plugins(&server).await; - 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 @@ -2582,7 +2696,11 @@ plugin_sharing = false mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; mount_empty_user_installed_plugins(&server).await; - 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 @@ -2669,11 +2787,12 @@ plugin_sharing = false ) .await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -2747,11 +2866,12 @@ plugin_sharing = false .await; mount_empty_user_installed_plugins(&server).await; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let plugin_installed_request_id = mcp @@ -2822,7 +2942,11 @@ async fn plugin_list_fetches_workspace_directory_kind_when_remote_plugin_disable mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; mount_empty_user_installed_plugins(&server).await; - 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 @@ -2948,7 +3072,11 @@ plugin_sharing = false mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) .await; - 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 @@ -3075,7 +3203,11 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; mount_empty_user_installed_plugins(&server).await; - 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 @@ -3232,7 +3364,11 @@ plugin_sharing = 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 @@ -3292,7 +3428,11 @@ plugin_sharing = true 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 @@ -3422,7 +3562,11 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { .await; } - 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 @@ -3482,7 +3626,11 @@ remote_plugin = true 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 @@ -3518,7 +3666,11 @@ async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Resul .mount(&server) .await; - 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 @@ -3557,7 +3709,12 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() -> .mount(&server) .await; - let mut mcp = TestAppServer::new_with_plugin_startup_tasks(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_plugin_startup_tasks() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; wait_for_featured_plugin_request_count(&server, /*expected_count*/ 1).await?; @@ -3938,6 +4095,7 @@ fn remote_installed_plugin_body_with_optional_app_manifest( "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", + "installation_policy_source": "WORKSPACE_SETTING", "authentication_policy": "ON_USE", "release": {{ "version": "{release_version}", diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 41b7990ec8..09cede4581 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -25,6 +25,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInstallPolicySource; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginShareDiscoverability; @@ -56,7 +57,11 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::test] async fn plugin_read_rejects_missing_read_source() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = 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 @@ -85,7 +90,11 @@ async fn plugin_read_rejects_missing_read_source() -> Result<()> { #[tokio::test] async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = 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 @@ -144,6 +153,7 @@ apps = true "name": "example-plugin", "scope": "GLOBAL", "installation_policy": "AVAILABLE", + "installation_policy_source": "IMPLICIT_CANONICAL_APP", "authentication_policy": "ON_USE", "release": { "version": "1.2.1", @@ -237,7 +247,11 @@ apps = true .mount(&server) .await; - 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 @@ -267,6 +281,10 @@ apps = true assert_eq!(response.plugin.summary.name, "example-plugin"); assert_eq!(response.plugin.summary.source, PluginSource::Remote); assert_eq!(response.plugin.summary.share_context, None); + assert_eq!( + response.plugin.summary.install_policy_source, + Some(PluginInstallPolicySource::ImplicitCanonicalApp) + ); assert_eq!( response .plugin @@ -378,7 +396,11 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< .mount(&server) .await; - 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??; for remote_marketplace_name in [ @@ -586,7 +608,11 @@ async fn plugin_read_includes_share_url_for_admin_disabled_remote_plugin() -> Re .mount(&server) .await; - 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 @@ -719,7 +745,11 @@ async fn plugin_skill_read_reads_remote_skill_contents_when_remote_plugin_enable .mount(&server) .await; - 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 @@ -771,7 +801,11 @@ async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> Result<( .mount(&server) .await; - 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 @@ -823,7 +857,11 @@ remote_plugin = true 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 @@ -853,7 +891,11 @@ remote_plugin = true async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> { let codex_home = TempDir::new()?; write_remote_plugin_catalog_config(codex_home.path(), "https://example.invalid/backend-api/")?; - let mut mcp = 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 @@ -911,7 +953,11 @@ enabled = true )?; write_installed_plugin(&codex_home, "openai-curated", "demo-plugin")?; - 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 marketplace_path = @@ -1015,7 +1061,11 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( .expect(1) .mount(&server) .await; - 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 @@ -1145,7 +1195,11 @@ async fn plugin_read_keeps_remote_version_when_share_principals_are_missing() -> .expect(1) .mount(&server) .await; - 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 @@ -1201,7 +1255,11 @@ async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() -> let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?; write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?; - let mut mcp = 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 @@ -1259,7 +1317,11 @@ async fn plugin_read_fails_on_malformed_share_mapping() -> Result<()> { "not valid json\n", )?; - 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 @@ -1453,7 +1515,11 @@ enabled = false )?; write_installed_plugin(&codex_home, "codex-curated", "demo-plugin")?; - 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 marketplace_path = @@ -1655,7 +1721,11 @@ async fn plugin_read_returns_app_metadata_category() -> Result<()> { let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - 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 @@ -1744,15 +1814,16 @@ async fn plugin_read_hides_apps_for_api_key_auth() -> Result<()> { let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - 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(&[ ("CODEX_ACCESS_TOKEN", None), ("CODEX_API_KEY", None), ("OPENAI_API_KEY", None), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1812,7 +1883,11 @@ async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> { )?; write_plugins_enabled_config(&codex_home)?; - 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 @@ -1874,7 +1949,11 @@ async fn plugin_read_describes_uninstalled_git_source_without_cloning() -> Resul )?; write_plugins_enabled_config(&codex_home)?; - 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 @@ -1937,7 +2016,11 @@ async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result< )?; write_plugins_enabled_config(&codex_home)?; - 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 @@ -1990,7 +2073,11 @@ async fn plugin_read_returns_invalid_request_when_plugin_manifest_is_missing() - )?; write_plugins_enabled_config(&codex_home)?; - 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 diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index a82b363806..493e6ae4e3 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -102,7 +102,11 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { .mount(&server) .await; - 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 expected_plugin_path = AbsolutePathBuf::try_from(plugin_path.clone())?; let request_id = mcp @@ -179,6 +183,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { installed: true, enabled: true, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), @@ -252,7 +257,11 @@ async fn plugin_share_save_forwards_access_policy() -> Result<()> { .mount(&server) .await; - 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 expected_plugin_path = AbsolutePathBuf::try_from(plugin_path)?; let request_id = mcp @@ -305,7 +314,11 @@ async fn plugin_share_save_rejects_listed_discoverability() -> 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 .send_raw_request( @@ -359,7 +372,11 @@ plugin_sharing = 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 .send_raw_request( @@ -404,7 +421,11 @@ async fn plugin_share_rejects_workspace_targets_from_client() -> 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 .send_raw_request( @@ -482,7 +503,11 @@ async fn plugin_share_save_rejects_access_policy_for_existing_plugin() -> 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 .send_raw_request( @@ -555,7 +580,11 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { .mount(&server) .await; - 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 .send_raw_request("plugin/share/list", Some(json!({}))) @@ -583,6 +612,7 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { installed: true, enabled: true, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), @@ -627,15 +657,16 @@ async fn plugin_share_checkout_adds_personal_marketplace_entry() -> Result<()> { mount_empty_remote_installed_plugins(&server, "WORKSPACE").await; let home_env = home.path().to_string_lossy().into_owned(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("HOME", Some(home_env.as_str())), ("USERPROFILE", Some(home_env.as_str())), (TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -790,15 +821,16 @@ async fn plugin_share_checkout_rejects_non_share_remote_plugin() -> Result<()> { mount_empty_remote_installed_plugins(&server, "GLOBAL").await; let home_env = home.path().to_string_lossy().into_owned(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("HOME", Some(home_env.as_str())), ("USERPROFILE", Some(home_env.as_str())), (TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -881,15 +913,16 @@ async fn plugin_share_checkout_cleans_up_path_when_marketplace_update_fails() -> mount_empty_remote_installed_plugins(&server, "WORKSPACE").await; let home_env = home.path().to_string_lossy().into_owned(); - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[ + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[ ("HOME", Some(home_env.as_str())), ("USERPROFILE", Some(home_env.as_str())), (TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -984,7 +1017,11 @@ async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { .mount(&server) .await; - 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 .send_raw_request( @@ -1065,7 +1102,11 @@ plugin_sharing = 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 .send_raw_request( @@ -1114,7 +1155,11 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { .mount(&server) .await; - 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 .send_raw_request( @@ -1184,6 +1229,7 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { installed: true, enabled: true, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index ea82bca08c..94efd617fc 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -42,7 +42,11 @@ enabled = true "#, )?; - 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 params = PluginUninstallParams { @@ -100,7 +104,11 @@ async fn plugin_uninstall_tracks_analytics_event() -> 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 @@ -162,7 +170,11 @@ async fn plugin_uninstall_rejects_remote_plugin_when_plugins_are_disabled() -> R plugins = 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_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -241,7 +253,11 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl )); std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?; - 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??; // Simulate a background remote-cache refresh removing the local bundle @@ -336,7 +352,11 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()> .join("plugins/cache/openai-curated-remote/linear"); std::fs::create_dir_all(global_cache_root.join("1.0.0/.codex-plugin"))?; - 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 @@ -410,7 +430,11 @@ async fn plugin_uninstall_accepts_workspace_remote_plugin_id_shape() -> Result<( r#"{"name":"skill-improver","version":"1.0.0"}"#, )?; - 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 @@ -459,7 +483,11 @@ async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() - )); std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?; - 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 @@ -501,7 +529,11 @@ async fn plugin_uninstall_rejects_remote_plugin_id_with_spaces_before_network_ca codex_home.path(), &format!("{}/backend-api/", server.uri()), )?; - 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 @@ -536,7 +568,11 @@ async fn plugin_uninstall_rejects_invalid_remote_plugin_id_before_network_call() codex_home.path(), &format!("{}/backend-api/", server.uri()), )?; - 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 @@ -571,7 +607,11 @@ async fn plugin_uninstall_rejects_empty_remote_plugin_id() -> Result<()> { codex_home.path(), &format!("{}/backend-api/", server.uri()), )?; - 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 diff --git a/codex-rs/app-server/tests/suite/v2/process_exec.rs b/codex-rs/app-server/tests/suite/v2/process_exec.rs index ac4f6eeda3..de7930e28d 100644 --- a/codex-rs/app-server/tests/suite/v2/process_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/process_exec.rs @@ -108,11 +108,12 @@ async fn process_spawn_returns_error_when_local_environment_is_disabled() -> Res let codex_home = TempDir::new()?; let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let process_request_id = mcp @@ -233,7 +234,11 @@ async fn process_kill_terminates_running_process() -> Result<()> { async fn initialized_mcp(codex_home: &Path) -> Result<(MockServer, TestAppServer)> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; create_config_toml(codex_home, &server.uri(), "never")?; - let mut mcp = TestAppServer::new(codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; Ok((server, mcp)) } diff --git a/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs b/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs index 86ea09504c..25da2cd85e 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs @@ -44,6 +44,7 @@ async fn consume_rate_limit_reset_credit_requires_chatgpt_auth() -> Result<()> { .send_consume_account_rate_limit_reset_credit_request( ConsumeAccountRateLimitResetCreditParams { idempotency_key: "request-1".to_string(), + credit_id: None, }, ) .await?; @@ -120,6 +121,44 @@ async fn consume_account_rate_limit_reset_credit_maps_backend_outcomes() -> Resu Ok(()) } +#[tokio::test] +async fn consume_account_rate_limit_reset_credit_forwards_selected_credit_id() -> Result<()> { + let (codex_home, server) = chatgpt_test_context().await?; + Mock::given(method("POST")) + .and(path("/api/codex/rate-limit-reset-credits/consume")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .and(body_json(json!({ + "redeem_request_id": "request-selected", + "credit_id": "credit-123", + }))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({ "code": "reset", "windows_reset": 2 })), + ) + .expect(1) + .mount(&server) + .await; + + let mut mcp = initialized_app_server(codex_home.path()).await?; + let request_id = mcp + .send_consume_account_rate_limit_reset_credit_request( + ConsumeAccountRateLimitResetCreditParams { + idempotency_key: "request-selected".to_string(), + credit_id: Some("credit-123".to_string()), + }, + ) + .await?; + + assert_eq!( + read_response::(&mut mcp, request_id).await?, + ConsumeAccountRateLimitResetCreditResponse { + outcome: ConsumeAccountRateLimitResetCreditOutcome::Reset, + } + ); + Ok(()) +} + #[tokio::test] async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key() -> Result<()> { let (codex_home, _server) = chatgpt_test_context().await?; @@ -129,6 +168,7 @@ async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key() .send_consume_account_rate_limit_reset_credit_request( ConsumeAccountRateLimitResetCreditParams { idempotency_key: String::new(), + credit_id: None, }, ) .await?; @@ -139,6 +179,26 @@ async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key() Ok(()) } +#[tokio::test] +async fn consume_account_rate_limit_reset_credit_rejects_empty_credit_id() -> Result<()> { + let (codex_home, _server) = chatgpt_test_context().await?; + let mut mcp = initialized_app_server(codex_home.path()).await?; + + let request_id = mcp + .send_consume_account_rate_limit_reset_credit_request( + ConsumeAccountRateLimitResetCreditParams { + idempotency_key: "request-1".to_string(), + credit_id: Some(String::new()), + }, + ) + .await?; + let error = read_error_response(&mut mcp, request_id).await?; + + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!(error.error.message, "creditId must not be empty"); + Ok(()) +} + #[tokio::test] async fn consume_account_rate_limit_reset_credit_surfaces_backend_failure() -> Result<()> { let (codex_home, server) = chatgpt_test_context().await?; @@ -177,14 +237,15 @@ async fn consume_timeout_releases_account_auth_queue() -> Result<()> { .mount(&server) .await; - 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), (RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR, Some("100")), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let consume_id = send_consume_reset_credit(&mut mcp, "request-timeout").await?; let account_id = mcp @@ -227,7 +288,12 @@ async fn chatgpt_test_context() -> Result<(TempDir, MockServer)> { } async fn initialized_app_server(codex_home: &Path) -> Result { - let mut mcp = TestAppServer::new_with_env(codex_home, &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home) + .without_auto_env() + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; Ok(mcp) } @@ -244,6 +310,7 @@ async fn send_consume_reset_credit(mcp: &mut TestAppServer, idempotency_key: &st mcp.send_consume_account_rate_limit_reset_credit_request( ConsumeAccountRateLimitResetCreditParams { idempotency_key: idempotency_key.to_string(), + credit_id: None, }, ) .await diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 58c31c453b..9c9cbc5ac5 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -10,7 +10,10 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RateLimitReachedType; +use codex_app_server_protocol::RateLimitResetCredit; +use codex_app_server_protocol::RateLimitResetCreditStatus; use codex_app_server_protocol::RateLimitResetCreditsSummary; +use codex_app_server_protocol::RateLimitResetType; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::RequestId; @@ -39,8 +42,12 @@ const INTERNAL_ERROR_CODE: i64 = -32603; async fn get_account_rate_limits_requires_auth() -> Result<()> { let codex_home = TempDir::new()?; - 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.send_get_account_rate_limits_request().await?; @@ -65,7 +72,11 @@ async fn get_account_rate_limits_requires_auth() -> Result<()> { async fn get_account_rate_limits_requires_chatgpt_auth() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = 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(&mut mcp, "sk-test-key").await?; @@ -109,6 +120,16 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { let secondary_reset_timestamp = chrono::DateTime::parse_from_rfc3339("2025-01-01T01:00:00Z") .expect("parse secondary reset timestamp") .timestamp(); + let reset_credit_granted_at = chrono::DateTime::parse_from_rfc3339("2026-06-17T00:00:00Z") + .expect("parse reset credit grant timestamp") + .timestamp(); + let reset_credit_expires_at = chrono::DateTime::parse_from_rfc3339("2026-07-17T00:00:00Z") + .expect("parse reset credit expiry timestamp") + .timestamp(); + let second_reset_credit_granted_at = + chrono::DateTime::parse_from_rfc3339("2026-06-18T00:00:00Z") + .expect("parse second reset credit grant timestamp") + .timestamp(); let response_body = json!({ "plan_type": "pro", "rate_limit": { @@ -170,9 +191,42 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { .expect(1) .mount(&server) .await; + Mock::given(method("GET")) + .and(path("/api/codex/rate-limit-reset-credits")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "credits": [ + { + "id": "credit-1", + "reset_type": "codex_rate_limits", + "status": "available", + "granted_at": "2026-06-17T00:00:00Z", + "expires_at": "2026-07-17T00:00:00Z", + "title": "Full reset (Weekly + 5 hr)", + "description": "Ready to redeem" + }, + { + "id": "credit-2", + "reset_type": "future_reset_type", + "status": "future_status", + "granted_at": "2026-06-18T00:00:00Z", + "expires_at": null + } + ], + "available_count": 2, + "total_earned_count": 4 + }))) + .expect(1) + .mount(&server) + .await; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + 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.send_get_account_rate_limits_request().await?; @@ -260,19 +314,112 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { .into_iter() .collect(), ), - rate_limit_reset_credits: Some(RateLimitResetCreditsSummary { available_count: 3 }), + rate_limit_reset_credits: Some(RateLimitResetCreditsSummary { + available_count: 2, + credits: Some(vec![ + RateLimitResetCredit { + id: "credit-1".to_string(), + reset_type: RateLimitResetType::CodexRateLimits, + status: RateLimitResetCreditStatus::Available, + granted_at: reset_credit_granted_at, + expires_at: Some(reset_credit_expires_at), + title: Some("Full reset (Weekly + 5 hr)".to_string()), + description: Some("Ready to redeem".to_string()), + }, + RateLimitResetCredit { + id: "credit-2".to_string(), + reset_type: RateLimitResetType::Unknown, + status: RateLimitResetCreditStatus::Unknown, + granted_at: second_reset_credit_granted_at, + expires_at: None, + title: None, + description: None, + }, + ]), + }), }; assert_eq!(received, expected); Ok(()) } +#[tokio::test] +async fn get_account_rate_limits_preserves_count_when_reset_credit_details_fail() -> Result<()> { + let codex_home = TempDir::new()?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + write_chatgpt_base_url(codex_home.path(), &server.uri())?; + + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plan_type": "pro", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 42, + "limit_window_seconds": 3600, + "reset_after_seconds": 120, + "reset_at": 1735689720 + } + }, + "rate_limit_reset_credits": { "available_count": 3 } + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/codex/rate-limit-reset-credits")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .expect(1) + .mount(&server) + .await; + + let mut mcp = TestAppServer::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.send_get_account_rate_limits_request().await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountRateLimitsResponse = to_response(response)?; + + assert_eq!( + received.rate_limit_reset_credits, + Some(RateLimitResetCreditsSummary { + available_count: 3, + credits: None, + }) + ); + + Ok(()) +} + #[tokio::test] async fn send_add_credits_nudge_email_requires_auth() -> Result<()> { let codex_home = TempDir::new()?; - 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 @@ -301,7 +448,11 @@ async fn send_add_credits_nudge_email_requires_auth() -> Result<()> { async fn send_add_credits_nudge_email_requires_chatgpt_auth() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = 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(&mut mcp, "sk-test-key").await?; @@ -355,8 +506,12 @@ async fn send_add_credits_nudge_email_posts_expected_body() -> Result<()> { .mount(&server) .await; - 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 @@ -399,8 +554,12 @@ async fn send_add_credits_nudge_email_maps_cooldown() -> Result<()> { .mount(&server) .await; - 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 @@ -443,8 +602,12 @@ async fn send_add_credits_nudge_email_surfaces_backend_failure() -> Result<()> { .mount(&server) .await; - 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 diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 26b46c4317..f85eb526f7 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -291,7 +291,11 @@ impl RealtimeE2eHarness { sandbox, )?; - 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??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -349,6 +353,7 @@ impl RealtimeE2eHarness { .mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs, + flush_transcript_tail_on_session_end: None, thread_id: self.thread_id.clone(), codex_response_item_prefix: codex_responses_as_items .unwrap_or(false) @@ -410,6 +415,7 @@ impl RealtimeE2eHarness { .send_thread_realtime_start_request(ThreadRealtimeStartParams { thread_id: self.thread_id.clone(), client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_response_item_prefix: codex_responses_as_items .unwrap_or(false) .then(|| RESPONSE_ITEM_PREFIX.to_string()), @@ -655,7 +661,10 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { StartupContextConfig::Generated, )?; - 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??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -672,6 +681,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -946,7 +956,10 @@ async fn realtime_start_can_skip_startup_context() -> Result<()> { StartupContextConfig::Generated, )?; - 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??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -963,6 +976,7 @@ async fn realtime_start_can_skip_startup_context() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1044,7 +1058,10 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript StartupContextConfig::Generated, )?; - 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??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -1061,6 +1078,7 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1153,7 +1171,11 @@ async fn realtime_list_voices_returns_supported_names() -> Result<()> { StartupContextConfig::Generated, )?; - 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 @@ -1225,7 +1247,10 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> { StartupContextConfig::Generated, )?; - 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??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -1242,6 +1267,7 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1328,7 +1354,10 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { StartupContextConfig::Override("startup context"), )?; - 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??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -1346,6 +1375,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2683,7 +2713,10 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> { StartupContextConfig::Override("startup context"), )?; - 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??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -2701,6 +2734,7 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2751,7 +2785,10 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> { StartupContextConfig::Generated, )?; - 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 thread_start_request_id = mcp @@ -2767,6 +2804,7 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, diff --git a/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs b/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs index d440f56903..609dc28a3d 100644 --- a/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs +++ b/codex-rs/app-server/tests/suite/v2/recommended_plugins.rs @@ -72,11 +72,13 @@ async fn first_turn_after_external_login_waits_for_recommended_plugins() -> Resu )?; let sqlite_home = codex_home.path().to_string_lossy(); - let mut app_server = TestAppServer::new_without_managed_config_with_env( - codex_home.path(), - &[("CODEX_SQLITE_HOME", Some(sqlite_home.as_ref()))], - ) - .await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .without_managed_config() + .with_env_overrides(&[("CODEX_SQLITE_HOME", Some(sqlite_home.as_ref()))]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; let access_token = encode_id_token( diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index 30f7f73beb..e6093ad0ee 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -129,7 +129,11 @@ async fn managed_requirements_reject_all_remote_control_rpcs() -> Result<()> { codex_home.path().join("requirements.toml"), "allow_remote_control = false\n", )?; - 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 notification = timeout( @@ -187,7 +191,11 @@ async fn managed_requirements_allow_remote_control_true_does_not_enable_or_block codex_home.path().join("requirements.toml"), "allow_remote_control = true\n", )?; - 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.send_remote_control_status_read_request().await?; @@ -270,7 +278,12 @@ async fn listen_off_honors_persisted_remote_control_enable() -> Result<()> { }) .await?; - let _app_server = TestAppServer::new_with_args(codex_home.path(), &["--listen", "off"]).await?; + let _app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_args(&["--listen", "off"]) + .build() + .await?; let request = timeout(STARTUP_TIMEOUT, read_http_request(&listener)).await??; assert!( request @@ -309,8 +322,12 @@ async fn listen_off_ignores_persisted_enable_when_disabled_by_requirements() -> }) .await?; - let mut app_server = - TestAppServer::new_with_args(codex_home.path(), &["--listen", "off"]).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_args(&["--listen", "off"]) + .build() + .await?; let status = timeout(STARTUP_TIMEOUT, app_server.wait_for_exit()).await??; assert!(!status.success()); timeout(Duration::from_millis(100), listener.accept()) @@ -357,8 +374,12 @@ async fn listen_off_exits_without_persisted_remote_control_enable() -> Result<() .await?; } - let mut app_server = - TestAppServer::new_with_args(codex_home.path(), &["--listen", "off"]).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_args(&["--listen", "off"]) + .build() + .await?; let status = timeout(STARTUP_TIMEOUT, app_server.wait_for_exit()).await??; assert!(!status.success()); } @@ -369,7 +390,11 @@ async fn listen_off_exits_without_persisted_remote_control_enable() -> Result<() async fn remote_control_disable_returns_disabled_status() -> Result<()> { let codex_home = TempDir::new()?; let _listener = configured_remote_control_listener(codex_home.path()).await?; - 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.send_remote_control_disable_request().await?; @@ -390,7 +415,11 @@ async fn remote_control_disable_returns_disabled_status() -> Result<()> { #[tokio::test] async fn remote_control_status_read_returns_disabled_status() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + 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.send_remote_control_status_read_request().await?; @@ -412,7 +441,11 @@ async fn remote_control_status_read_returns_disabled_status() -> Result<()> { async fn remote_control_enable_returns_connecting_status() -> Result<()> { let codex_home = TempDir::new()?; let mut backend = BlockingRemoteControlBackend::start(codex_home.path()).await?; - 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.send_remote_control_enable_request().await?; @@ -448,7 +481,11 @@ async fn disable_waits_for_in_flight_durable_enable() -> Result<()> { let websocket_url = backend.websocket_url().to_string(); let state_db = StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()).await?; - 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??; mcp.send_remote_control_enable_request().await?; @@ -480,7 +517,11 @@ async fn rpc_updates_durable_preference_but_ephemeral_does_not() -> Result<()> { let state_db = StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()).await?; - 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.send_remote_control_enable_request().await?; @@ -537,7 +578,11 @@ async fn rpc_updates_durable_preference_but_ephemeral_does_not() -> Result<()> { async fn remote_control_status_read_returns_connecting_status_after_enable() -> Result<()> { let codex_home = TempDir::new()?; let mut backend = BlockingRemoteControlBackend::start(codex_home.path()).await?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + 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.send_remote_control_enable_request().await?; @@ -572,7 +617,11 @@ async fn remote_control_status_read_returns_connecting_status_after_enable() -> async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()> { let codex_home = TempDir::new()?; let mut backend = PairingRemoteControlBackend::start(codex_home.path()).await?; - 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.send_remote_control_enable_request().await?; @@ -669,7 +718,11 @@ async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()> async fn pairing_start_works_after_ephemeral_enable() -> Result<()> { let codex_home = TempDir::new()?; let mut backend = PairingRemoteControlBackend::start(codex_home.path()).await?; - 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.send_remote_control_ephemeral_enable_request().await?; wait_for_response(&mut mcp, request_id).await?; @@ -707,7 +760,11 @@ async fn pairing_start_works_after_ephemeral_enable() -> Result<()> { async fn remote_control_client_management_works_while_disabled() -> Result<()> { let codex_home = TempDir::new()?; let mut backend = ClientManagementRemoteControlBackend::start(codex_home.path()).await?; - 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 diff --git a/codex-rs/app-server/tests/suite/v2/request_permissions.rs b/codex-rs/app-server/tests/suite/v2/request_permissions.rs index ee836de5ed..b30e63edb4 100644 --- a/codex-rs/app-server/tests/suite/v2/request_permissions.rs +++ b/codex-rs/app-server/tests/suite/v2/request_permissions.rs @@ -37,7 +37,10 @@ async fn request_permissions_round_trip() -> Result<()> { let server = create_mock_responses_server_sequence(responses).await; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_start_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/request_user_input.rs b/codex-rs/app-server/tests/suite/v2/request_user_input.rs index 711858becd..d28b8a3dbc 100644 --- a/codex-rs/app-server/tests/suite/v2/request_user_input.rs +++ b/codex-rs/app-server/tests/suite/v2/request_user_input.rs @@ -62,7 +62,10 @@ async fn request_user_input_round_trip() -> Result<()> { let server = create_mock_responses_server_sequence(responses).await; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_start_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/request_validation.rs b/codex-rs/app-server/tests/suite/v2/request_validation.rs index 4ed239e7f8..1ffdf8cab3 100644 --- a/codex-rs/app-server/tests/suite/v2/request_validation.rs +++ b/codex-rs/app-server/tests/suite/v2/request_validation.rs @@ -30,7 +30,10 @@ async fn request_handlers_reject_remote_image_urls() -> Result<()> { "http://localhost/unused", "http://localhost/unused", )?; - 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 thread_request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/review.rs b/codex-rs/app-server/tests/suite/v2/review.rs index 37a0c6f219..06f0242476 100644 --- a/codex-rs/app-server/tests/suite/v2/review.rs +++ b/codex-rs/app-server/tests/suite/v2/review.rs @@ -59,7 +59,10 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<() let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_id = start_default_thread(&mut mcp).await?; @@ -173,7 +176,10 @@ async fn review_start_exec_approval_item_id_matches_command_execution_item() -> let codex_home = TempDir::new()?; create_config_toml_with_approval_policy(codex_home.path(), &server.uri(), "untrusted")?; - let mut mcp = 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 thread_id = start_default_thread(&mut mcp).await?; @@ -256,7 +262,10 @@ async fn review_start_rejects_empty_base_branch() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_id = start_default_thread(&mut mcp).await?; @@ -299,7 +308,10 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<( let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_id = start_default_thread(&mut mcp).await?; @@ -377,7 +389,10 @@ async fn review_start_rejects_empty_commit_sha() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_id = start_default_thread(&mut mcp).await?; @@ -412,7 +427,10 @@ async fn review_start_rejects_empty_custom_instructions() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_id = start_default_thread(&mut mcp).await?; diff --git a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs index 57de35ea4e..f7839fee9e 100644 --- a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs +++ b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs @@ -49,7 +49,10 @@ async fn openai_model_header_mismatch_emits_model_rerouted_notification_v2() -> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_req = mcp @@ -116,7 +119,10 @@ async fn cyber_policy_response_emits_typed_error_notification_v2() -> Result<()> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_req = mcp @@ -193,7 +199,10 @@ async fn response_model_field_mismatch_emits_model_rerouted_notification_v2_when let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_req = mcp @@ -262,7 +271,10 @@ async fn model_verification_emits_typed_notification_and_warning_v2() -> Result< let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_req = mcp @@ -336,7 +348,10 @@ async fn turn_moderation_metadata_emits_typed_notification_v2() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - 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 thread_req = mcp diff --git a/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs b/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs index 6a70116aef..62817677ac 100644 --- a/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs +++ b/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs @@ -28,6 +28,7 @@ use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; +use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use core_test_support::process::wait_for_pid_file; @@ -139,7 +140,11 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume() ) .await; - let mut app_server = TestAppServer::new(fixture.codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(fixture.codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(READ_TIMEOUT, app_server.initialize()).await??; let thread_id = start_thread( &mut app_server, @@ -185,7 +190,11 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume() drop(app_server); std::fs::remove_file(&fixture.pid_file)?; - let mut app_server = TestAppServer::new(fixture.codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(fixture.codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(READ_TIMEOUT, app_server.initialize()).await??; let request_id = app_server .send_thread_resume_request(ThreadResumeParams { @@ -235,7 +244,9 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume() for request in &requests[1..4] { assert_selected_skill_is_injected(request, /*expected_count*/ 1); assert_selected_plugin_tools(request); + assert_plugin_guidance_count(request, /*expected_count*/ 1); } + assert_plugin_guidance_count(&requests[4], /*expected_count*/ 1); assert_selected_skill_is_injected(&requests[5], /*expected_count*/ 2); assert_selected_plugin_tools(&requests[5]); let output = requests[2].function_call_output(MCP_CALL_ID); @@ -330,7 +341,11 @@ async fn selected_capabilities_become_available_between_samples_in_one_turn() -> ) .await; - let mut app_server = TestAppServer::new(fixture.codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(fixture.codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(READ_TIMEOUT, app_server.initialize()).await??; let thread_id = start_thread( &mut app_server, @@ -398,7 +413,9 @@ async fn selected_capabilities_become_available_between_samples_in_one_turn() -> assert_eq!(3, requests.len()); assert_selected_skill_catalog_available(&requests[1]); assert_selected_plugin_tools(&requests[1]); + assert_plugin_guidance_count(&requests[1], /*expected_count*/ 1); assert_selected_plugin_tools(&requests[2]); + assert_plugin_guidance_count(&requests[2], /*expected_count*/ 1); let output = requests[2].function_call_output(MCP_CALL_ID); let output = output["output"] .as_str() @@ -537,6 +554,7 @@ fn assert_selected_capabilities_absent(request: &ResponsesRequest) { .all(|text| !text.contains(SKILL_DESCRIPTION)) ); assert_selected_plugin_tools_absent(request); + assert_plugin_guidance_count(request, /*expected_count*/ 0); } fn assert_selected_plugin_tools_absent(request: &ResponsesRequest) { @@ -555,6 +573,17 @@ fn assert_selected_plugin_tools_absent(request: &ResponsesRequest) { ); } +fn assert_plugin_guidance_count(request: &ResponsesRequest, expected_count: usize) { + assert_eq!( + expected_count, + request + .message_input_texts("developer") + .into_iter() + .filter(|text| text.starts_with(PLUGINS_INSTRUCTIONS_OPEN_TAG)) + .count() + ); +} + fn assert_selected_skill_is_injected(request: &ResponsesRequest, expected_count: usize) { assert_selected_skill_catalog_available(request); diff --git a/codex-rs/app-server/tests/suite/v2/selected_environment.rs b/codex-rs/app-server/tests/suite/v2/selected_environment.rs new file mode 100644 index 0000000000..6dd90eb51a --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/selected_environment.rs @@ -0,0 +1,261 @@ +use std::collections::BTreeMap; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use app_test_support::PathBufExt; +use app_test_support::TestAppServer; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::UserInput as V2UserInput; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const AGENTS_INSTRUCTIONS: &str = "selected environment workspace instructions"; +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); + +fn text_turn_params(thread_id: String, prompt: &str) -> TurnStartParams { + TurnStartParams { + thread_id, + input: vec![V2UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + } +} + +#[tokio::test] +async fn thread_start_reports_selected_environment_metadata() -> Result<()> { + let server = responses::start_mock_server().await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 100_000, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + + let request_id = app_server + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + cwd, + runtime_workspace_roots, + active_permission_profile, + .. + } = to_response(response)?; + let host_cwd = codex_home.path().to_path_buf().abs().canonicalize()?; + let cwd = cwd.canonicalize()?; + let runtime_workspace_roots = runtime_workspace_roots + .into_iter() + .map(|root| root.canonicalize()) + .collect::>>()?; + assert_eq!( + (cwd, runtime_workspace_roots, active_permission_profile), + ( + // TODO(anp): Return the selected environment's native cwd from thread/start. + host_cwd.clone(), + // TODO(anp): Derive runtime workspace roots from the selected remote environment. + vec![host_cwd], + // TODO(anp): Report the implicit built-in permission profile instead of None. + None, + ) + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_reports_selected_environment_instruction_source() -> Result<()> { + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "done"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 100_000, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + + let (agents_source, environment_cwd) = { + let auto_env = app_server.auto_env()?; + let environment_cwd = auto_env.selection().cwd.clone(); + let agents_source = environment_cwd.join("AGENTS.md")?; + auto_env + .environment() + .get_filesystem() + .write_file( + &agents_source, + AGENTS_INSTRUCTIONS.as_bytes().to_vec(), + /*sandbox*/ None, + ) + .await?; + (agents_source, environment_cwd) + }; + + let request_id = app_server + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadStartResponse = to_response(response)?; + + assert_eq!(response.instruction_sources, vec![agents_source.into()]); + timeout( + DEFAULT_READ_TIMEOUT, + app_server.start_turn_and_wait_for_completion(text_turn_params( + response.thread.id, + "inspect workspace instructions", + )), + ) + .await??; + + let user_context = response_mock.single_request().message_input_texts("user"); + let instructions = user_context + .iter() + .find(|text| text.starts_with("# AGENTS.md instructions")) + .context("selected environment instructions should be model visible")?; + let expected_instructions = format!( + "# AGENTS.md instructions for {}\n\n\n{AGENTS_INSTRUCTIONS}\n", + environment_cwd.inferred_native_path_string() + ); + assert_eq!(instructions, &expected_instructions); + + Ok(()) +} + +#[tokio::test] +async fn turn_model_context_uses_selected_environment() -> Result<()> { + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "done"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 100_000, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + let (environment_cwd, environment_shell) = { + let auto_env = app_server.auto_env()?; + ( + auto_env.selection().cwd.clone(), + auto_env.environment().info().await?.shell.name, + ) + }; + + let request_id = app_server + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + thread, + runtime_workspace_roots, + .. + } = to_response(response)?; + timeout( + DEFAULT_READ_TIMEOUT, + app_server.start_turn_and_wait_for_completion(text_turn_params( + thread.id, + "inspect the selected environment", + )), + ) + .await??; + + let user_context = response_mock.single_request().message_input_texts("user"); + let environment_context = user_context + .iter() + .find(|text| text.starts_with("")) + .context("selected environment context should be model visible")?; + let shell = environment_context + .lines() + .find(|line| line.trim_start().starts_with("")) + .map(str::trim) + .map(str::to_string); + let cwd = environment_context + .lines() + .find(|line| line.trim_start().starts_with("")) + .map(str::trim) + .map(str::to_string); + assert_eq!( + (shell, cwd), + ( + Some(format!("{environment_shell}")), + Some(format!( + "{}", + environment_cwd.inferred_native_path_string() + )), + ) + ); + let [runtime_workspace_root] = runtime_workspace_roots.as_slice() else { + anyhow::bail!("expected one runtime workspace root"); + }; + let expected_workspace_roots = format!( + "{}", + runtime_workspace_root.as_path().display() + ); + // TODO(anp): Derive model-visible workspace roots from the selected remote environment and + // render them using its native path convention. + assert!(environment_context.contains(&expected_workspace_roots)); + + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 5993bf6da2..e64eda0f22 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -186,7 +186,11 @@ enabled = true 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 disablement_request_id = mcp @@ -342,7 +346,11 @@ async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result .mount(&server) .await; } - 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 stale_skills_list_request_id = mcp @@ -467,7 +475,12 @@ async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disable .mount(&server) .await; - 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 @@ -513,11 +526,12 @@ async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> { "---\nname: repo-skill\ndescription: from repo root\n---\n\n# Body\n", )?; - let mut mcp = TestAppServer::new_with_env( - codex_home.path(), - &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -557,7 +571,11 @@ async fn skills_list_accepts_relative_cwds() -> Result<()> { let relative_cwd = std::path::PathBuf::from("relative-cwd"); std::fs::create_dir_all(codex_home.path().join(&relative_cwd))?; - let mut mcp = 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 @@ -585,7 +603,11 @@ async fn skills_list_preserves_requested_cwd_order() -> Result<()> { let first_cwd = TempDir::new()?; let second_cwd = 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??; let request_id = mcp @@ -621,7 +643,11 @@ async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> { let codex_home = TempDir::new()?; let cwd = 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??; // Seed the cwd cache before the cwd-local skill exists. @@ -707,7 +733,11 @@ async fn skills_extra_roots_set_updates_process_runtime_roots() -> Result<()> { "---\nname: runtime-skill\ndescription: runtime skill\n---\n\n# Body\n", )?; - 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 set_request_id = mcp @@ -813,7 +843,11 @@ async fn skills_extra_roots_set_updates_process_runtime_roots() -> Result<()> { ); drop(mcp); - 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 skills_request_id = mcp .send_skills_list_request(SkillsListParams { @@ -849,9 +883,12 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( )?; write_skill(&codex_home, "demo")?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[(CODEX_EXEC_SERVER_URL_ENV_VAR, None)]) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[(CODEX_EXEC_SERVER_URL_ENV_VAR, None)]) + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let initial_skills_request_id = mcp .send_skills_list_request(SkillsListParams { diff --git a/codex-rs/app-server/tests/suite/v2/sleep.rs b/codex-rs/app-server/tests/suite/v2/sleep.rs index 8d88816fde..96885ae8d8 100644 --- a/codex-rs/app-server/tests/suite/v2/sleep.rs +++ b/codex-rs/app-server/tests/suite/v2/sleep.rs @@ -56,7 +56,10 @@ async fn external_sleep_polls_current_time_and_emits_items() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - 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 thread_start_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_archive.rs b/codex-rs/app-server/tests/suite/v2/thread_archive.rs index 9de06f1128..31e2c9067d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_archive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_archive.rs @@ -38,7 +38,10 @@ async fn thread_archive_requires_materialized_rollout() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - 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??; // Start a thread. @@ -221,7 +224,11 @@ async fn thread_archive_archives_spawned_descendants() -> Result<()> { ) .await?; - 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 archive_id = mcp @@ -342,7 +349,11 @@ async fn thread_archive_succeeds_when_descendant_archive_fails() -> Result<()> { .join(child_rollout_path.file_name().expect("rollout file name")); std::fs::create_dir_all(&archived_child_path)?; - let mut mcp = 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 archive_id = mcp @@ -446,7 +457,11 @@ async fn thread_archive_succeeds_when_spawned_descendant_is_missing() -> Result< ) .await?; - 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 archive_id = mcp @@ -499,7 +514,10 @@ async fn thread_archive_clears_stale_subscriptions_before_resume() -> Result<()> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -539,7 +557,10 @@ async fn thread_archive_clears_stale_subscriptions_before_resume() -> Result<()> .await??; primary.clear_message_buffer(); - let mut secondary = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut secondary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; let archive_id = primary diff --git a/codex-rs/app-server/tests/suite/v2/thread_delete.rs b/codex-rs/app-server/tests/suite/v2/thread_delete.rs index c9a64c5ffe..a2aaf7a119 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_delete.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_delete.rs @@ -55,7 +55,11 @@ async fn thread_delete_deletes_spawned_descendants() -> Result<()> { .await?; } - 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 delete_id = mcp @@ -122,7 +126,10 @@ fn create_delete_test_rollout(codex_home: &Path, minute: u8, preview: &str) -> R async fn thread_delete_handles_live_threads_before_rollout_exists() -> Result<()> { let codex_home = TempDir::new()?; - 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 start_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index d7c1707a32..0a2d1ff453 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -118,7 +118,11 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { append_rollout_item_to_path(&original_path, &RolloutItem::SessionMeta(session_meta)).await?; let original_contents = std::fs::read_to_string(&original_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 fork_id = mcp @@ -257,7 +261,11 @@ async fn thread_fork_at_last_turn_id_keeps_only_terminal_prefix() -> Result<()> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - 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 start_id = mcp @@ -382,7 +390,11 @@ async fn thread_fork_inherits_explicit_source_name_from_session_index() -> Resul let source_name = "Renamed parent thread"; append_thread_name(codex_home.path(), source_thread_id, source_name).await?; - 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 fork_id = mcp @@ -433,7 +445,11 @@ async fn thread_fork_can_load_source_by_path() -> Result<()> { "rollout-2025-01-05T12-00-00-{conversation_id}.jsonl" )); - 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 fork_id = mcp @@ -473,7 +489,11 @@ async fn thread_fork_emits_restored_token_usage_before_next_turn() -> Result<()> Some("mock_provider"), )?; - 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 fork_id = mcp @@ -527,7 +547,11 @@ async fn thread_fork_can_exclude_turns_and_skip_restored_token_usage() -> Result Some("mock_provider"), )?; - 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 fork_id = mcp @@ -578,7 +602,12 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - 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_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -622,7 +651,10 @@ async fn thread_fork_rejects_unmaterialized_thread() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - 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 start_id = mcp @@ -676,7 +708,11 @@ async fn thread_fork_with_empty_path_uses_thread_id() -> Result<()> { /*git_info*/ None, )?; - 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 fork_id = mcp @@ -750,17 +786,18 @@ async fn thread_fork_surfaces_cloud_config_bundle_load_errors() -> Result<()> { )?; let refresh_token_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_token_url.as_str()), ), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -813,7 +850,11 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<() /*git_info*/ None, )?; - 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 fork_id = mcp @@ -967,7 +1008,11 @@ async fn pathless_ephemeral_thread_rejects_codex_home_path_after_reload() -> Res )?; let side_thread_id = { - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; let fork_id = app_server @@ -1012,7 +1057,11 @@ async fn pathless_ephemeral_thread_rejects_codex_home_path_after_reload() -> Res thread.id }; - let mut app_server = TestAppServer::new(codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; let codex_home_path = codex_home.path().to_path_buf(); diff --git a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs index 966e96e6a5..14acdf7be9 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs @@ -36,7 +36,10 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_req = mcp @@ -153,7 +156,10 @@ async fn thread_inject_items_adds_raw_response_items_after_a_turn() -> Result<() let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_req = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 8ade0bd382..26cf6c56ad 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -50,7 +50,11 @@ use uuid::Uuid; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); async fn init_mcp(codex_home: &Path) -> Result { - let mut mcp = TestAppServer::new(codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; Ok(mcp) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_loaded_list.rs b/codex-rs/app-server/tests/suite/v2/thread_loaded_list.rs index 62c22e2729..d083d8279f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_loaded_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_loaded_list.rs @@ -21,7 +21,10 @@ async fn thread_loaded_list_returns_loaded_thread_ids() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_id = start_thread(&mut mcp).await?; @@ -51,7 +54,10 @@ async fn thread_loaded_list_paginates() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - 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 first = start_thread(&mut mcp).await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_memory_mode_set.rs b/codex-rs/app-server/tests/suite/v2/thread_memory_mode_set.rs index 498fb959ad..98395414c7 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_memory_mode_set.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_memory_mode_set.rs @@ -27,7 +27,10 @@ async fn thread_memory_mode_set_updates_loaded_thread_state() -> Result<()> { create_config_toml(codex_home.path(), &server.uri())?; let state_db = init_state_db(codex_home.path()).await?; - let mut mcp = 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 start_id = mcp @@ -79,7 +82,11 @@ async fn thread_memory_mode_set_updates_stored_thread_state() -> Result<()> { )?; let thread_uuid = ThreadId::from_string(&thread_id)?; - 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??; for mode in [ThreadMemoryMode::Disabled, ThreadMemoryMode::Enabled] { diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs index e20a5fab44..fd3f6ab16b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -41,7 +41,10 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 start_id = mcp @@ -136,7 +139,10 @@ async fn thread_metadata_update_rejects_empty_git_info_patch() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 start_id = mcp @@ -182,7 +188,10 @@ async fn thread_metadata_update_rejects_ephemeral_thread() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 start_id = mcp @@ -244,7 +253,11 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_stored_thread() - /*git_info*/ None, )?; - 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 update_id = mcp @@ -309,7 +322,11 @@ async fn thread_metadata_update_repairs_loaded_thread_without_resetting_summary( ) .await; - 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 resume_id = mcp @@ -387,7 +404,11 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_archived_thread() ); fs::rename(&archived_source, &archived_dest)?; - 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 update_id = mcp @@ -443,7 +464,11 @@ async fn thread_metadata_update_can_clear_stored_git_fields() -> Result<()> { )?; let _state_db = init_state_db(codex_home.path()).await?; - 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 update_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 22adba37c8..4162e7ebd6 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -104,7 +104,11 @@ async fn thread_read_returns_summary_without_turns() -> Result<()> { /*git_info*/ None, )?; - 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 read_id = mcp @@ -159,7 +163,11 @@ async fn thread_read_can_include_turns() -> Result<()> { /*git_info*/ None, )?; - 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 read_id = mcp @@ -218,7 +226,11 @@ async fn paginated_stored_thread_allows_metadata_discovery_and_rejects_legacy_hi ThreadHistoryMode::Paginated, )?; - 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 read_id = mcp @@ -332,7 +344,11 @@ async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> { append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; - let mut mcp = 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 read_id = mcp @@ -421,7 +437,11 @@ async fn thread_turns_list_supports_requested_items_view() -> Result<()> { append_agent_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "draft")?; append_agent_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "final")?; - let mut mcp = 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 full = read_single_turn_items_view( @@ -725,7 +745,11 @@ async fn thread_read_can_return_archived_threads_by_id() -> Result<()> { archived_dir.join(active_rollout_path.file_name().expect("rollout file name")); std::fs::rename(&active_rollout_path, &archived_rollout_path)?; - let mut mcp = 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 read_id = mcp @@ -769,7 +793,11 @@ async fn thread_resume_initial_turns_page_matches_requested_turns_list_page() -> append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let turns_list_id = mcp @@ -840,7 +868,11 @@ async fn thread_turns_list_rejects_cursor_when_anchor_turn_is_rolled_back() -> R append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; - let mut mcp = 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 read_id = mcp @@ -907,7 +939,11 @@ async fn thread_read_returns_forked_from_id_for_forked_threads() -> Result<()> { /*git_info*/ None, )?; - 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 fork_id = mcp @@ -947,7 +983,10 @@ async fn thread_read_loaded_thread_returns_precomputed_path_before_materializati let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 start_id = mcp @@ -1007,7 +1046,11 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> { /*git_info*/ None, )?; - 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??; // Set a user-facing thread title. @@ -1155,7 +1198,10 @@ async fn thread_read_include_turns_rejects_unmaterialized_loaded_thread() -> Res let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 start_id = mcp @@ -1206,7 +1252,10 @@ async fn thread_turns_list_rejects_unmaterialized_loaded_thread() -> Result<()> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 start_id = mcp @@ -1260,7 +1309,11 @@ async fn thread_items_list_returns_unsupported() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - 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 read_id = mcp @@ -1298,7 +1351,10 @@ async fn thread_read_reports_system_error_idle_flag_after_failed_turn() -> Resul let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 start_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index a6e851f1d7..a9c3a1f700 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -155,7 +155,10 @@ async fn thread_resume_rejects_unmaterialized_thread() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - 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??; // Start a thread. @@ -202,7 +205,10 @@ async fn thread_resume_with_empty_path_uses_running_thread_id() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - 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 start_id = mcp @@ -270,7 +276,11 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul let project_agents = workspace.path().join("AGENTS.md"); std::fs::write(&project_agents, "project instructions")?; - 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 start_id = mcp @@ -348,7 +358,11 @@ async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Resul let extra_root = extra_root_tmp.path().join("extra-root"); std::fs::create_dir_all(&extra_root)?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -427,7 +441,12 @@ async fn thread_goal_get_rejects_unmaterialized_thread() -> Result<()> { config.replace("personality = true\n", "personality = true\ngoals = true\n"), )?; - 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_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -486,11 +505,13 @@ async fn goal_first_live_thread_appears_in_state_db_thread_list() -> Result<()> .as_path() .to_str() .expect("test codex home should be utf-8"); - let mut mcp = TestAppServer::new_without_managed_config_with_env( - &codex_home_path, - &[("CODEX_SQLITE_HOME", Some(sqlite_home))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home_path) + .without_auto_env() + .without_managed_config() + .with_env_overrides(&[("CODEX_SQLITE_HOME", Some(sqlite_home))]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -582,7 +603,12 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { "codex_work_desktop", )?; - 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_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -626,7 +652,12 @@ async fn thread_resume_running_thread_tracks_thread_originator_in_analytics() -> create_config_toml_with_chatgpt_base_url(codex_home.path(), &server.uri(), &server.uri())?; mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = 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_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -745,7 +776,11 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { /*git_info*/ None, )?; - 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 resume_id = mcp @@ -854,7 +889,7 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<( !remote_turn .items .iter() - .any(|item| matches!(item, ThreadItem::ImageGeneration { .. })), + .any(|item| matches!(item, ThreadItem::ImageGeneration(_))), "remote resume should drop image generation items for {client_name}" ); } @@ -908,12 +943,9 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<( assert!( normal_turn.items.iter().any(|item| matches!( item, - ThreadItem::ImageGeneration { - result, - revised_prompt, - .. - } if result == "base64-image-result" - && revised_prompt.as_deref() == Some("secret revised prompt") + ThreadItem::ImageGeneration(item) + if item.result == "base64-image-result" + && item.revised_prompt.as_deref() == Some("secret revised prompt") )), "normal resume should keep image generation items" ); @@ -943,7 +975,11 @@ async fn resume_redaction_fixture(client_name: Option<&str>) -> Result Result<()> { /*git_info*/ None, )?; - 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 resume_id = mcp @@ -1100,7 +1140,11 @@ async fn thread_resume_rejects_archived_session_by_id() -> Result<()> { archived_dir.join(active_rollout_path.file_name().expect("rollout file name")), )?; - 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 resume_id = mcp @@ -1139,7 +1183,12 @@ async fn thread_resume_keeps_paused_goal_paused() -> Result<()> { config.replace("personality = true\n", "personality = true\ngoals = true\n"), )?; - 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_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -1244,7 +1293,12 @@ async fn thread_goal_set_preserves_budget_limited_same_objective() -> Result<()> config.replace("personality = true\n", "personality = true\ngoals = true\n"), )?; - 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_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -1343,7 +1397,12 @@ async fn thread_goal_set_persists_resumable_stopped_statuses() -> Result<()> { config.replace("personality = true\n", "personality = true\ngoals = true\n"), )?; - 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_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -1438,7 +1497,12 @@ async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> /*git_info*/ None, )?; - 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_READ_TIMEOUT, mcp.initialize()).await??; let goal_id = mcp @@ -1550,7 +1614,12 @@ async fn thread_goal_lifecycle_emits_analytics_and_clear_deletes_goal() -> Resul )?; mount_analytics_capture(&server, codex_home.path()).await?; - 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_READ_TIMEOUT.saturating_mul(2), mcp.initialize()).await??; let start_id = mcp @@ -1735,7 +1804,11 @@ async fn thread_resume_emits_restored_token_usage_before_next_turn() -> Result<( Some("mock_provider"), )?; - 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 resume_id = mcp @@ -1788,7 +1861,11 @@ async fn thread_resume_skips_restored_token_usage_when_turns_are_excluded() -> R Some("mock_provider"), )?; - 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 first_resume_id = mcp @@ -1896,7 +1973,11 @@ async fn thread_resume_token_usage_replay_ignores_stale_interrupted_tail_turn() format!("{persisted_rollout}{appended_rollout}\n"), )?; - 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 resume_id = mcp @@ -2020,7 +2101,11 @@ async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Re format!("{persisted_rollout}{appended_rollout}\n"), )?; - 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 resume_id = mcp @@ -2214,7 +2299,11 @@ stream_max_retries = 0 .mark_backfill_complete(/*last_watermark*/ None) .await?; - 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 update_id = mcp @@ -2308,7 +2397,11 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is format!("{persisted_rollout}{appended_rollout}\n"), )?; - 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 resume_id = mcp @@ -2382,7 +2475,11 @@ async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let rollout = setup_rollout_fixture(codex_home.path(), &server.uri()).await?; let thread_id = rollout.conversation_id.clone(); - 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 read_id = mcp @@ -2504,7 +2601,11 @@ async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = TestAppServer::new(codex_home.path()).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -2543,7 +2644,11 @@ async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> { .await??; primary.clear_message_buffer(); - let mut secondary = TestAppServer::new(codex_home.path()).await?; + let mut secondary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; let turn_id = primary @@ -2613,7 +2718,11 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = TestAppServer::new(codex_home.path()).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -2732,7 +2841,11 @@ async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = TestAppServer::new(codex_home.path()).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -2901,7 +3014,11 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = TestAppServer::new(codex_home.path()).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -3032,7 +3149,11 @@ async fn thread_resume_can_skip_turns_when_thread_is_running() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = TestAppServer::new(codex_home.path()).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -3070,7 +3191,11 @@ async fn thread_resume_can_skip_turns_when_thread_is_running() -> Result<()> { ) .await??; - let mut secondary = TestAppServer::new(codex_home.path()).await?; + let mut secondary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; let resume_id = secondary @@ -3116,7 +3241,11 @@ async fn thread_resume_replays_pending_command_execution_request_approval() -> R let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = TestAppServer::new(codex_home.path()).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -3255,7 +3384,11 @@ async fn thread_resume_replays_pending_file_change_request_approval() -> Result< let server = create_mock_responses_server_sequence_unchecked(responses).await; create_config_toml(&codex_home, &server.uri())?; - let mut primary = TestAppServer::new(&codex_home).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -3476,7 +3609,11 @@ async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> R let rollout = setup_rollout_fixture(codex_home.path(), &server.uri()).await?; create_config_toml_with_required_broken_mcp(codex_home.path(), &server.uri())?; - 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 resume_id = mcp @@ -3555,17 +3692,18 @@ async fn thread_resume_surfaces_cloud_config_bundle_load_errors() -> Result<()> /*git_info*/ None, )?; let refresh_token_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_token_url.as_str()), ), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -3649,7 +3787,11 @@ async fn thread_resume_can_load_source_by_external_path() -> Result<()> { )?; let thread_path = rollout_path(external_home.path(), "2025-01-05T12-00-00", &thread_id); - let mut mcp = 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 resume_id = mcp .send_thread_resume_request(ThreadResumeParams { @@ -3739,7 +3881,11 @@ async fn start_materialized_thread_and_restart( codex_home: &Path, seed_text: &str, ) -> Result { - let mut first_mcp = TestAppServer::new(codex_home).await?; + let mut first_mcp = TestAppServer::builder() + .with_codex_home(codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, first_mcp.initialize()).await??; let start_id = first_mcp @@ -3798,7 +3944,11 @@ async fn start_materialized_thread_and_restart( drop(first_mcp); - let mut second_mcp = TestAppServer::new(codex_home).await?; + let mut second_mcp = TestAppServer::builder() + .with_codex_home(codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, second_mcp.initialize()).await??; Ok(RestartedThreadFixture { @@ -3829,7 +3979,11 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = TestAppServer::new(codex_home.path()).await?; + let mut primary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -3867,7 +4021,11 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { ) .await??; - let mut secondary = TestAppServer::new(codex_home.path()).await?; + let mut secondary = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; let resume_id = secondary diff --git a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs index 328ae529c8..53f549c2e2 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs @@ -28,7 +28,10 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs #[tokio::test] async fn thread_rollback_does_not_emit_deprecation_notice_to_codex_tui() -> Result<()> { let codex_home = TempDir::new()?; - 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?; let initialized = timeout( DEFAULT_READ_TIMEOUT, mcp.initialize_with_client_info(ClientInfo { @@ -80,7 +83,10 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<() let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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??; // Start a thread. diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs index 29dbee26d1..bd7b7b366c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -44,7 +44,11 @@ async fn thread_settings_update_emits_notification_and_updates_future_turns() -> write_models_cache(codex_home.path())?; let (model_id, service_tier_id) = service_tier_model_and_tier_id()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let thread = start_thread(&mut mcp).await?.thread; @@ -107,7 +111,11 @@ async fn thread_settings_update_cwd_retargets_default_environment() -> Result<() let workspace = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - 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 thread = start_thread(&mut mcp).await?.thread; @@ -157,7 +165,11 @@ async fn thread_settings_update_while_turn_is_active_emits_notification() -> Res let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - 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 thread = start_thread(&mut mcp).await?.thread; start_text_turn(&mut mcp, thread.id.clone()).await?; @@ -200,7 +212,11 @@ async fn thread_settings_update_null_service_tier_uses_default() -> Result<()> { write_models_cache(codex_home.path())?; let (model_id, service_tier_id) = service_tier_model_and_tier_id()?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let thread = start_thread(&mut mcp).await?.thread; @@ -266,7 +282,11 @@ async fn thread_settings_update_rejects_sandbox_policy_with_permissions() -> Res let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - 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 thread = start_thread(&mut mcp).await?.thread; @@ -300,7 +320,11 @@ async fn turn_start_settings_override_emits_thread_settings_updated() -> Result< let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - 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 thread = start_thread(&mut mcp).await?.thread; timeout( diff --git a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs index df7b4991d6..03184ab80b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs @@ -60,7 +60,11 @@ async fn thread_shell_command_history_responses_exclude_persisted_command_execut &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new(codex_home.as_path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.as_path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -193,11 +197,12 @@ async fn thread_shell_command_returns_error_when_local_environment_is_disabled() &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new_with_env( - codex_home.as_path(), - &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], - ) - .await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.as_path()) + .without_auto_env() + .with_env_overrides(&[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -252,7 +257,11 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new(codex_home.as_path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.as_path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index f717dd0014..c98adb40f6 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -7,14 +7,18 @@ use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TextPosition; +use codex_app_server_protocol::TextRange; use codex_app_server_protocol::ThreadHistoryMode; use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; @@ -54,6 +58,17 @@ use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +const EXEC_POLICY_PARSE_WARNING_SUMMARY: &str = "Error parsing rules; custom rules not applied."; + +fn is_exec_policy_config_warning(notification: &JSONRPCNotification) -> bool { + notification.method == "configWarning" + && notification + .params + .as_ref() + .and_then(|params| params.get("summary")) + .and_then(Value::as_str) + == Some(EXEC_POLICY_PARSE_WARNING_SUMMARY) +} async fn start_thread_with_model( mcp: &mut TestAppServer, @@ -84,7 +99,10 @@ async fn thread_start_provider_model_fallback_applies_to_configured_model() -> R model = "gpt-5.4-mini" "#, )?; - 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 request_id = mcp @@ -104,6 +122,130 @@ model = "gpt-5.4-mini" Ok(()) } +#[tokio::test] +async fn thread_start_warns_for_exec_policy_parse_failure_after_initialize() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let rules_dir = codex_home.path().join("rules"); + std::fs::create_dir_all(&rules_dir)?; + let rules_path = rules_dir.join("broken.rules"); + std::fs::write(&rules_path, "prefix_rule(")?; + let rules_path = std::fs::canonicalize(rules_path)?; + + let request_id = mcp + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: ThreadStartResponse = to_response(response)?; + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_matching_notification( + "exec-policy configWarning", + is_exec_policy_config_warning, + ), + ) + .await??; + let notification: ServerNotification = notification.try_into()?; + let ServerNotification::ConfigWarning(warning) = notification else { + anyhow::bail!("unexpected notification variant"); + }; + let ConfigWarningNotification { + summary, + details, + path, + range, + } = warning; + assert_eq!( + (summary, range), + ( + "Error parsing rules; custom rules not applied.".to_string(), + Some(TextRange { + start: TextPosition { + line: 1, + column: 13, + }, + end: TextPosition { + line: 1, + column: 13, + }, + }), + ) + ); + let path = path.context("warning should include a path")?; + assert_eq!( + normalize_path_for_comparison(path), + normalize_path_for_comparison(&rules_path) + ); + let details = details.context("warning should include details")?; + assert!( + details.contains("failed to parse rules file") && details.contains("broken.rules"), + "unexpected warning details: {details}" + ); + assert!( + details.contains("Parse error"), + "unexpected warning details: {details}" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_does_not_repeat_initialize_exec_policy_warning() -> Result<()> { + let codex_home = TempDir::new()?; + let rules_dir = codex_home.path().join("rules"); + std::fs::create_dir_all(&rules_dir)?; + std::fs::write(rules_dir.join("broken.rules"), "prefix_rule(")?; + + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_matching_notification( + "initialize exec-policy configWarning", + is_exec_policy_config_warning, + ), + ) + .await??; + + let request_id = mcp + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: ThreadStartResponse = to_response(response)?; + + let duplicate_warning = timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_matching_notification( + "duplicate exec-policy configWarning", + is_exec_policy_config_warning, + ), + ) + .await; + assert!( + duplicate_warning.is_err(), + "thread/start repeated the initialize exec-policy warning" + ); + + Ok(()) +} + #[tokio::test] async fn thread_start_provider_model_fallback_uses_bedrock_static_catalog() -> Result<()> { let codex_home = TempDir::new()?; @@ -112,7 +254,10 @@ async fn thread_start_provider_model_fallback_uses_bedrock_static_catalog() -> R r#"model_provider = "amazon-bedrock" "#, )?; - 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 unsupported_with_fallback = start_thread_with_model( @@ -150,7 +295,10 @@ async fn thread_start_provider_model_fallback_ignores_dynamic_catalog() -> Resul let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_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 response = start_thread_with_model( @@ -173,7 +321,10 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; // Start server and initialize. - let mut mcp = 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??; // Start a v2 thread with an explicit model override. @@ -320,7 +471,10 @@ async fn thread_start_history_mode_accepts_legacy_and_rejects_paginated() -> Res let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - 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 request_id = mcp @@ -369,7 +523,11 @@ async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { let extra_root = cwd.join("extra-root"); std::fs::create_dir_all(&extra_root)?; - 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 @@ -410,7 +568,11 @@ async fn thread_start_excludes_profile_workspace_roots_from_runtime_workspace_ro profile_root.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 req_id = mcp @@ -445,7 +607,11 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = 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 @@ -480,7 +646,11 @@ async fn thread_start_rejects_relative_environment_cwd_as_invalid_request() -> R let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new(codex_home.path()).await?; + 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 @@ -519,7 +689,11 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<( let project_agents_path = workspace.path().join("AGENTS.md"); std::fs::write(&project_agents_path, "project instructions")?; - let mut mcp = 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 @@ -566,7 +740,11 @@ async fn thread_start_response_excludes_empty_project_instruction_source() -> Re let project_agents_path = workspace.path().join("AGENTS.md"); std::fs::write(project_agents_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 @@ -609,7 +787,11 @@ async fn thread_start_without_selected_environment_includes_only_global_instruct let workspace = TempDir::new()?; std::fs::write(workspace.path().join("AGENTS.md"), "project instructions")?; - 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 @@ -699,7 +881,12 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { create_config_toml_with_chatgpt_base_url(codex_home.path(), &server.uri(), &server.uri())?; mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = 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_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -749,7 +936,11 @@ model_reasoning_effort = "high" )?; set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; - 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 @@ -779,7 +970,10 @@ async fn thread_start_drops_unsupported_service_tier_id() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - 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 service_tier_id = "experimental-tier-id".to_string(); @@ -809,7 +1003,10 @@ async fn thread_start_accepts_default_service_tier() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - 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 req_id = mcp @@ -840,7 +1037,10 @@ async fn thread_start_accepts_metrics_service_name() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = 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 req_id = mcp @@ -867,7 +1067,10 @@ async fn thread_start_ephemeral_remains_pathless() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = 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 req_id = mcp @@ -913,7 +1116,10 @@ async fn thread_start_fails_when_required_mcp_server_fails_to_initialize() -> Re let codex_home = TempDir::new()?; create_config_toml_with_required_broken_mcp(codex_home.path(), &server.uri())?; - let mut mcp = 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 req_id = mcp @@ -949,7 +1155,10 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result< let codex_home = TempDir::new()?; create_config_toml_with_optional_broken_mcp(codex_home.path(), &server.uri())?; - let mut mcp = 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 req_id = mcp @@ -1083,17 +1292,18 @@ async fn thread_start_surfaces_cloud_config_bundle_load_errors() -> Result<()> { )?; let refresh_token_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_token_url.as_str()), ), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -1143,7 +1353,11 @@ model_reasoning_effort = "high" "#, )?; - 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 first_request = mcp @@ -1203,7 +1417,11 @@ async fn thread_start_with_nested_git_cwd_trusts_repo_root() -> Result<()> { let nested = repo_root.path().join("nested/project"); std::fs::create_dir_all(&nested)?; - let mut mcp = 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 @@ -1241,7 +1459,11 @@ async fn thread_start_with_read_only_sandbox_does_not_persist_project_trust() -> let workspace = 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_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1279,7 +1501,11 @@ async fn thread_start_preserves_untrusted_project_trust() -> Result<()> { std::fs::write(&config_path, config_toml.to_string())?; let config_before = std::fs::read_to_string(&config_path)?; - let mut mcp = 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 @@ -1320,7 +1546,11 @@ model_reasoning_effort = "high" set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; let config_before = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - let mut mcp = 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 diff --git a/codex-rs/app-server/tests/suite/v2/thread_status.rs b/codex-rs/app-server/tests/suite/v2/thread_status.rs index 364175b7ae..957eb2ce14 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_status.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_status.rs @@ -28,8 +28,12 @@ async fn thread_status_changed_emits_runtime_updates() -> Result<()> { let server = create_mock_responses_server_sequence(responses).await; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("RUST_LOG", Some("info"))]).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("RUST_LOG", Some("info"))]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp @@ -135,7 +139,10 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> { let server = create_mock_responses_server_sequence(responses).await; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; let message = timeout( DEFAULT_READ_TIMEOUT, mcp.initialize_with_capabilities( diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 07999e1650..66d51d2780 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -59,7 +59,10 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 start_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs index 9c9ff4b767..a1d5c8b4d7 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs @@ -42,7 +42,11 @@ async fn thread_unsubscribe_keeps_thread_loaded_until_idle_timeout() -> Result<( let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_id = start_thread(&mut mcp).await?; @@ -121,7 +125,11 @@ async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> { let final_response_completed = completions.remove(0); create_config_toml(&codex_home, server.uri())?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -253,7 +261,11 @@ async fn thread_unsubscribe_preserves_cached_status_before_idle_unload() -> Resu let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_id = start_thread(&mut mcp).await?; @@ -340,7 +352,11 @@ async fn thread_unsubscribe_reports_not_subscribed_before_idle_unload() -> Resul let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = 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 thread_id = start_thread(&mut mcp).await?; diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 3dc17780e8..28e9294225 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -57,7 +57,11 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Start a v2 thread and capture its id. @@ -141,7 +145,10 @@ async fn turn_interrupt_rejects_completed_turn() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; - let mut mcp = TestAppServer::new_with_auto_env(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -236,7 +243,11 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() .await; create_config_toml(&codex_home, &server.uri(), "untrusted", "read-only")?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index b42a645a33..62b13a1e52 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -132,7 +132,10 @@ async fn run_local_image_turn(detail: Option) -> Result> &BTreeMap::default(), )?; - 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 thread_req = mcp @@ -229,7 +232,10 @@ async fn turn_start_with_empty_input_runs_model_request() -> Result<()> { &BTreeMap::default(), )?; - 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 thread_req = mcp @@ -331,7 +337,10 @@ async fn turn_start_additional_context_flows_to_model_input() -> Result<()> { &BTreeMap::default(), )?; - 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 thread_req = mcp @@ -408,7 +417,10 @@ async fn turn_start_sends_originator_header() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - 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_with_client_info(ClientInfo { @@ -485,7 +497,10 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - 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 thread_req = mcp @@ -601,14 +616,15 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills( write_test_skill(codex_home.path(), "beta-skill")?; let isolated_home = codex_home.path().to_string_lossy(); - 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(&[ ("HOME", Some(isolated_home.as_ref())), ("USERPROFILE", Some(isolated_home.as_ref())), - ], - ) - .await?; + ]) + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -701,7 +717,10 @@ async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> { .expect("bundled model catalog should include a picker model with service tiers"); let service_tier_id = service_tier_model.service_tiers[0].id.clone(); - let mut mcp = 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 thread_req = mcp @@ -765,7 +784,10 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> &BTreeMap::default(), )?; - 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 thread_req = mcp @@ -867,7 +889,12 @@ async fn turn_start_tracks_thread_originator_in_analytics() -> Result<()> { std::fs::write(config_path, config)?; mount_analytics_capture(&server, codex_home.path()).await?; - 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_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -992,7 +1019,12 @@ async fn turn_profile_tracks_blocking_tool_and_follow_up_sampling() -> Result<() )?; mount_analytics_capture(&server, codex_home.path()).await?; - 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_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -1093,7 +1125,10 @@ async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - 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 thread_req = mcp @@ -1153,7 +1188,10 @@ async fn turn_start_rejects_combined_oversized_text_input() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - 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 thread_req = mcp @@ -1233,7 +1271,10 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() "sandbox_mode = \"read-only\"\n", )?; - 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 thread_req = mcp @@ -1305,7 +1346,11 @@ async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result &BTreeMap::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 thread_req = mcp @@ -1380,7 +1425,10 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( &BTreeMap::from([(Feature::Personality, true)]), )?; - 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??; // Start a thread (v2) and capture its id. @@ -1525,7 +1573,10 @@ async fn turn_start_accepts_collaboration_mode_override_v2() -> Result<()> { &BTreeMap::default(), )?; - 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 thread_req = mcp @@ -1611,7 +1662,10 @@ async fn turn_start_uses_thread_feature_overrides_for_request_user_input_tool_de &BTreeMap::default(), )?; - 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 thread_req = mcp @@ -1696,7 +1750,10 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - 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 thread_req = mcp @@ -1773,7 +1830,10 @@ async fn turn_start_ignores_deprecated_multi_agent_mode() -> Result<()> { &BTreeMap::from([(Feature::MultiAgentV2, true)]), )?; - 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 thread_req = mcp @@ -1850,7 +1910,10 @@ async fn thread_start_ignores_deprecated_multi_agent_mode() -> Result<()> { &BTreeMap::from([(Feature::MultiAgentV2, true)]), )?; - 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 thread_req = mcp @@ -1938,7 +2001,10 @@ async fn turn_start_change_personality_mid_thread_v2() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - 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 thread_req = mcp @@ -2054,7 +2120,10 @@ async fn turn_start_uses_migrated_pragmatic_personality_without_override_v2() -> /*git_info*/ None, )?; - 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 persisted_toml: ConfigToml = toml::from_str(&std::fs::read_to_string( @@ -2185,7 +2254,11 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new(codex_home.as_path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.as_path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // thread/start @@ -2331,7 +2404,11 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new(codex_home.as_path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.as_path()) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -2484,7 +2561,11 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // thread/start @@ -2678,7 +2759,11 @@ stream_max_retries = 0 ), )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -2789,7 +2874,11 @@ url = "ws://127.0.0.1:1" "#, )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; for case in [ @@ -2944,7 +3033,11 @@ async fn turn_start_file_change_approval_v2() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp @@ -3135,7 +3228,11 @@ async fn turn_start_does_not_stream_apply_patch_change_updates_without_feature_v let server = create_mock_responses_server_sequence(responses).await; create_config_toml(&codex_home, &server.uri(), "never", &BTreeMap::default())?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp @@ -3273,7 +3370,11 @@ async fn turn_start_streams_apply_patch_change_updates_v2() -> Result<()> { model["apply_patch_tool_type"] = serde_json::Value::from("freeform"); std::fs::write(&cache_path, serde_json::to_string_pretty(&cache)?)?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp @@ -3405,7 +3506,10 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<() &BTreeMap::from([(Feature::Collab, true)]), )?; - 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 thread_req = mcp @@ -3623,7 +3727,10 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected() -> Result<()> { )?; write_models_cache(codex_home.path())?; - 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 thread_req = mcp @@ -3801,7 +3908,10 @@ config_file = "./custom-role.toml" ), )?; - 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 thread_req = mcp @@ -3945,7 +4055,11 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp @@ -4118,7 +4232,11 @@ async fn turn_start_file_change_approval_decline_v2() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = TestAppServer::new(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp @@ -4265,7 +4383,11 @@ async fn command_execution_notifications_include_process_id() -> Result<()> { "danger-full-access", )?; - 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 start_id = mcp @@ -4399,7 +4521,11 @@ async fn turn_start_with_elevated_override_does_not_persist_project_trust() -> R let workspace = 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_READ_TIMEOUT, mcp.initialize()).await??; let thread_request = mcp diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index c6f8f0e198..533f925798 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -746,12 +746,13 @@ async fn create_zsh_test_mcp_process( ) -> Result { let app_server = create_test_package_app_server(codex_home, zsh_path)?; let zdotdir = zdotdir.to_string_lossy().into_owned(); - TestAppServer::new_with_program_and_env( - codex_home, - &app_server, - &[("ZDOTDIR", Some(zdotdir.as_str()))], - ) - .await + TestAppServer::builder() + .with_codex_home(codex_home) + .without_auto_env() + .with_program(&app_server) + .with_env_overrides(&[("ZDOTDIR", Some(zdotdir.as_str()))]) + .build() + .await } fn create_test_package_app_server(codex_home: &Path, zsh_path: &Path) -> Result { diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index ed0ea2def5..10ad608ec3 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -50,7 +50,12 @@ async fn turn_steer_requires_active_turn() -> Result<()> { )?; mount_analytics_capture(&server, &codex_home).await?; - let mut mcp = TestAppServer::new_without_managed_config(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -136,7 +141,12 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { )?; mount_analytics_capture(&server, &codex_home).await?; - let mut mcp = TestAppServer::new_without_managed_config(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -250,7 +260,12 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { )?; mount_analytics_capture(&server, &codex_home).await?; - let mut mcp = TestAppServer::new_without_managed_config(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -387,7 +402,12 @@ async fn turn_steer_rejects_context_only_input_without_merging_context() -> Resu )?; mount_analytics_capture(&server, &codex_home).await?; - let mut mcp = TestAppServer::new_without_managed_config(&codex_home).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .without_auto_env() + .without_managed_config() + .build() + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs index f8c115d746..f7d05395b4 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -78,8 +78,12 @@ async fn standalone_web_search_round_trips_output() -> 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 thread_req = mcp @@ -196,8 +200,12 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { assert_eq!(completed.item, expected_completed_item); drop(mcp); - let mut reloaded_mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut reloaded_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, reloaded_mcp.initialize()).await??; let read_req = reloaded_mcp .send_thread_read_request(ThreadReadParams { diff --git a/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs b/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs index afb2b2ab33..e562c6b8e8 100644 --- a/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs +++ b/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs @@ -31,7 +31,11 @@ async fn windows_sandbox_setup_start_emits_completion_notification() -> Result<( "mock_provider", "compact prompt", )?; - 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 @@ -66,7 +70,11 @@ async fn windows_sandbox_setup_start_emits_completion_notification() -> Result<( #[tokio::test] async fn windows_sandbox_setup_start_rejects_relative_cwd() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = 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 diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml index f7b0c8b0f5..4ff5fb9b86 100644 --- a/codex-rs/backend-client/Cargo.toml +++ b/codex-rs/backend-client/Cargo.toml @@ -19,7 +19,7 @@ serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } codex-backend-openapi-models = { path = "../codex-backend-openapi-models" } codex-api = { workspace = true } -codex-client = { workspace = true } +codex-http-client = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-protocol = { workspace = true } diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index be444b6247..a5db25618b 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -9,8 +9,8 @@ use crate::types::TokenUsageProfile; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; use codex_api::SharedAuthProvider; -use codex_client::build_reqwest_client_with_custom_ca; -use codex_client::with_chatgpt_cloudflare_cookie_store; +use codex_http_client::build_reqwest_client_with_custom_ca; +use codex_http_client::with_chatgpt_cloudflare_cookie_store; use codex_login::CodexAuth; use codex_login::default_client::get_codex_user_agent; use codex_protocol::account::PlanType as AccountPlanType; diff --git a/codex-rs/backend-client/src/client/rate_limit_resets.rs b/codex-rs/backend-client/src/client/rate_limit_resets.rs index b22a4ec91a..bed56ba648 100644 --- a/codex-rs/backend-client/src/client/rate_limit_resets.rs +++ b/codex-rs/backend-client/src/client/rate_limit_resets.rs @@ -3,6 +3,7 @@ use super::Client; use super::PathStyle; use crate::types::ConsumeRateLimitResetCreditResponse; +use crate::types::RateLimitResetCreditsDetails; use crate::types::RateLimitStatusWithResetCredits; use crate::types::RateLimitsWithResetCredits; use anyhow::Result; @@ -13,6 +14,8 @@ use serde::Serialize; #[derive(Serialize)] struct ConsumeRateLimitResetCreditRequest<'a> { redeem_request_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + credit_id: Option<&'a str>, } impl Client { @@ -31,9 +34,34 @@ impl Client { self.decode_json(&url, &ct, &body) } + pub async fn list_rate_limit_reset_credits(&self) -> Result { + let url = self.rate_limit_reset_credits_url(); + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request(req, "GET", &url).await?; + self.decode_json(&url, &ct, &body) + } + pub async fn consume_rate_limit_reset_credit( &self, redeem_request_id: &str, + ) -> Result { + self.consume_rate_limit_reset_credit_request(redeem_request_id, /*credit_id*/ None) + .await + } + + pub async fn consume_rate_limit_reset_credit_by_id( + &self, + redeem_request_id: &str, + credit_id: &str, + ) -> Result { + self.consume_rate_limit_reset_credit_request(redeem_request_id, Some(credit_id)) + .await + } + + async fn consume_rate_limit_reset_credit_request( + &self, + redeem_request_id: &str, + credit_id: Option<&str>, ) -> Result { let url = self.consume_rate_limit_reset_credit_url(); let req = self @@ -41,7 +69,10 @@ impl Client { .post(&url) .headers(self.headers()) .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) - .json(&ConsumeRateLimitResetCreditRequest { redeem_request_id }); + .json(&ConsumeRateLimitResetCreditRequest { + redeem_request_id, + credit_id, + }); let (body, ct) = self.exec_request(req, "POST", &url).await?; self.decode_json(&url, &ct, &body) } @@ -53,6 +84,17 @@ impl Client { } } + fn rate_limit_reset_credits_url(&self) -> String { + match self.path_style { + PathStyle::CodexApi => { + format!("{}/api/codex/rate-limit-reset-credits", self.base_url) + } + PathStyle::ChatGptApi => { + format!("{}/wham/rate-limit-reset-credits", self.base_url) + } + } + } + fn consume_rate_limit_reset_credit_url(&self) -> String { match self.path_style { PathStyle::CodexApi => { diff --git a/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs b/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs index 89313dbe5d..f706be5da9 100644 --- a/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs +++ b/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs @@ -1,5 +1,7 @@ use super::*; use crate::types::ConsumeRateLimitResetCreditCode; +use crate::types::RateLimitResetCreditDetails; +use crate::types::RateLimitResetCreditsDetails; use crate::types::RateLimitResetCreditsSummary; use pretty_assertions::assert_eq; @@ -9,6 +11,10 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() { test_client("https://example.test", PathStyle::CodexApi).rate_limit_status_url(), "https://example.test/api/codex/usage" ); + assert_eq!( + test_client("https://example.test", PathStyle::CodexApi).rate_limit_reset_credits_url(), + "https://example.test/api/codex/rate-limit-reset-credits" + ); assert_eq!( test_client("https://example.test", PathStyle::CodexApi) .consume_rate_limit_reset_credit_url(), @@ -19,6 +25,11 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() { .rate_limit_status_url(), "https://chatgpt.com/backend-api/wham/usage" ); + assert_eq!( + test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi) + .rate_limit_reset_credits_url(), + "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits" + ); assert_eq!( test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi) .consume_rate_limit_reset_credit_url(), @@ -28,10 +39,22 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() { assert_eq!( serde_json::to_value(ConsumeRateLimitResetCreditRequest { redeem_request_id: "redeem-123", + credit_id: None, }) .unwrap(), serde_json::json!({ "redeem_request_id": "redeem-123" }) ); + assert_eq!( + serde_json::to_value(ConsumeRateLimitResetCreditRequest { + redeem_request_id: "redeem-456", + credit_id: Some("credit-123"), + }) + .unwrap(), + serde_json::json!({ + "redeem_request_id": "redeem-456", + "credit_id": "credit-123", + }) + ); let status: RateLimitStatusWithResetCredits = serde_json::from_value(serde_json::json!({ "plan_type": "plus", @@ -43,6 +66,60 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() { Some(RateLimitResetCreditsSummary { available_count: 3 }) ); + let details: RateLimitResetCreditsDetails = serde_json::from_value(serde_json::json!({ + "credits": [ + { + "id": "credit-1", + "reset_type": "codex_rate_limits", + "status": "available", + "granted_at": "2026-06-17T00:00:00Z", + "expires_at": "2026-07-17T00:00:00Z", + "redeem_started_at": null, + "redeemed_at": null, + "profile_image_url": "https://example.test/avatar.png", + "profile_user_id": "@friend", + "title": "Full reset (Weekly + 5 hr)", + "description": "Ready to redeem" + }, + { + "id": "credit-2", + "reset_type": "codex_rate_limits", + "status": "available", + "granted_at": "2026-06-18T00:00:00Z", + "expires_at": null + } + ], + "available_count": 2, + "total_earned_count": 4 + })) + .unwrap(); + assert_eq!( + details, + RateLimitResetCreditsDetails { + credits: vec![ + RateLimitResetCreditDetails { + id: "credit-1".to_string(), + reset_type: "codex_rate_limits".to_string(), + status: "available".to_string(), + granted_at: "2026-06-17T00:00:00Z".to_string(), + expires_at: Some("2026-07-17T00:00:00Z".to_string()), + title: Some("Full reset (Weekly + 5 hr)".to_string()), + description: Some("Ready to redeem".to_string()), + }, + RateLimitResetCreditDetails { + id: "credit-2".to_string(), + reset_type: "codex_rate_limits".to_string(), + status: "available".to_string(), + granted_at: "2026-06-18T00:00:00Z".to_string(), + expires_at: None, + title: None, + description: None, + }, + ], + available_count: 2, + } + ); + let response: ConsumeRateLimitResetCreditResponse = serde_json::from_value(serde_json::json!({ "code": "reset", "credit": { "id": "ignored-by-cli" }, diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 6cbeaf30b2..1e244e7d03 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -15,9 +15,12 @@ pub use types::ConfigBundleResponse; pub use types::ConsumeRateLimitResetCreditCode; pub use types::ConsumeRateLimitResetCreditResponse; pub use types::DeliveredConfigToml; +pub use types::DeliveredManagedLayers; pub use types::DeliveredRequirementsToml; pub use types::DeliveredTomlFragment; pub use types::PaginatedListTaskListItem; +pub use types::RateLimitResetCreditDetails; +pub use types::RateLimitResetCreditsDetails; pub use types::RateLimitResetCreditsSummary; pub use types::RateLimitsWithResetCredits; pub use types::TaskListItem; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 18ab323986..615fefd5fc 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -1,6 +1,7 @@ pub use codex_backend_openapi_models::models::ConfigBundleResponse; pub use codex_backend_openapi_models::models::CreditStatusDetails; pub use codex_backend_openapi_models::models::DeliveredConfigToml; +pub use codex_backend_openapi_models::models::DeliveredManagedLayers; pub use codex_backend_openapi_models::models::DeliveredRequirementsToml; pub use codex_backend_openapi_models::models::DeliveredTomlFragment; pub use codex_backend_openapi_models::models::PaginatedListTaskListItem; @@ -23,6 +24,23 @@ pub struct RateLimitResetCreditsSummary { pub available_count: i64, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct RateLimitResetCreditsDetails { + pub credits: Vec, + pub available_count: i64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct RateLimitResetCreditDetails { + pub id: String, + pub reset_type: String, + pub status: String, + pub granted_at: String, + pub expires_at: Option, + pub title: Option, + pub description: Option, +} + #[derive(Clone, Debug, PartialEq)] pub struct RateLimitsWithResetCredits { pub rate_limits: Vec, diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index ef587cd1d4..cfb9316e91 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -1327,6 +1327,7 @@ fn stored_auth_mode(auth: &codex_login::AuthDotJson) -> &'static str { AuthMode::ApiKey => "api_key", AuthMode::Chatgpt => "chatgpt", AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", + AuthMode::Headers => "headers", AuthMode::AgentIdentity => "agent_identity", AuthMode::PersonalAccessToken => "personal_access_token", AuthMode::BedrockApiKey => "bedrock_api_key", @@ -1397,6 +1398,9 @@ fn stored_auth_issues( issues.push("external ChatGPT auth is missing refresh metadata"); } } + AuthMode::Headers => { + issues.push("header auth cannot be loaded from auth storage"); + } AuthMode::AgentIdentity => { if auth .agent_identity @@ -2466,6 +2470,7 @@ fn auth_mode_name(auth: &CodexAuth) -> &'static str { AuthMode::ApiKey => "api_key", AuthMode::Chatgpt => "chatgpt", AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", + AuthMode::Headers => "headers", AuthMode::AgentIdentity => "agent_identity", AuthMode::PersonalAccessToken => "personal_access_token", AuthMode::BedrockApiKey => "bedrock_api_key", @@ -2605,6 +2610,7 @@ fn provider_auth_reachability_mode_from_auth( Some( AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens + | AuthMode::Headers | AuthMode::AgentIdentity | AuthMode::PersonalAccessToken, ) diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index d3f9b7b96b..98d56bec12 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -449,6 +449,9 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { eprintln!("Logged in using ChatGPT"); std::process::exit(0); } + AuthMode::Headers => { + unreachable!("header auth cannot be loaded from auth storage") + } AuthMode::AgentIdentity => { eprintln!("Logged in using access token"); std::process::exit(0); diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index fd0b1a0e4f..848180a517 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -963,6 +963,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re let approval_mode = match approval_mode { AppToolApproval::Auto => "auto", AppToolApproval::Prompt => "prompt", + AppToolApproval::Writes => "writes", AppToolApproval::Approve => "approve", }; println!(" default_tools_approval_mode: {approval_mode}"); diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index dfd4fe50ce..92562520fb 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -19,11 +19,14 @@ use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; use codex_login::auth::AgentIdentityAuth; use codex_login::auth::AgentIdentityAuthRecord; +use codex_login::auth::ExternalAuth; +use codex_login::auth::ExternalAuthRefreshContext; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::VecDeque; use std::future::pending; use std::path::Path; +use std::sync::RwLock; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use tempfile::tempdir; @@ -142,26 +145,20 @@ fn chatgpt_auth_json_with_last_refresh( refresh_token: &str, last_refresh: &str, ) -> serde_json::Value { - chatgpt_auth_json_with_mode( - plan_type, - chatgpt_user_id, - account_id, - access_token, - refresh_token, - last_refresh, - /*auth_mode*/ None, - ) + let fake_jwt = fake_chatgpt_jwt(plan_type, chatgpt_user_id, b"sig"); + json!({ + "OPENAI_API_KEY": null, + "tokens": { + "id_token": fake_jwt, + "access_token": access_token, + "refresh_token": refresh_token, + "account_id": account_id, + }, + "last_refresh": last_refresh, + }) } -fn chatgpt_auth_json_with_mode( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - access_token: &str, - refresh_token: &str, - last_refresh: &str, - auth_mode: Option<&str>, -) -> serde_json::Value { +fn fake_chatgpt_jwt(plan_type: &str, chatgpt_user_id: Option<&str>, signature: &[u8]) -> String { let header = json!({ "alg": "none", "typ": "JWT" }); let auth_payload = json!({ "chatgpt_plan_type": plan_type, @@ -174,23 +171,8 @@ fn chatgpt_auth_json_with_mode( }); let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header")); let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload")); - let signature_b64 = URL_SAFE_NO_PAD.encode(b"sig"); - let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - - let mut auth_json = json!({ - "OPENAI_API_KEY": null, - "tokens": { - "id_token": fake_jwt, - "access_token": access_token, - "refresh_token": refresh_token, - "account_id": account_id, - }, - "last_refresh": last_refresh, - }); - if let Some(auth_mode) = auth_mode { - auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string()); - } - auth_json + let signature_b64 = URL_SAFE_NO_PAD.encode(signature); + format!("{header_b64}.{payload_b64}.{signature_b64}") } fn test_bundle() -> CloudConfigBundle { @@ -319,6 +301,39 @@ struct UnauthorizedBundleClient { request_count: AtomicUsize, } +struct TestExternalChatgptAuth { + current: RwLock, + refreshed: CodexAuth, + refresh_count: AtomicUsize, +} + +impl ExternalAuth for TestExternalChatgptAuth { + fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async { + self.current + .read() + .map(|auth| auth.clone()) + .map_err(|_| std::io::Error::other("external auth lock is poisoned")) + }) + } + + fn refresh( + &self, + _context: ExternalAuthRefreshContext, + ) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async { + let refreshed = self.refreshed.clone(); + *self + .current + .write() + .map_err(|_| std::io::Error::other("external auth lock is poisoned"))? = + refreshed.clone(); + self.refresh_count.fetch_add(1, Ordering::SeqCst); + Ok(refreshed) + }) + } +} + impl BundleClient for UnauthorizedBundleClient { async fn get_bundle(&self, _auth: &CodexAuth) -> Result { self.request_count.fetch_add(1, Ordering::SeqCst); @@ -855,26 +870,13 @@ async fn get_bundle_surfaces_auth_recovery_message() { } #[tokio::test] -async fn get_bundle_unauthorized_without_recovery_uses_generic_message() { +async fn get_bundle_refreshes_external_auth_after_unauthorized() { let auth_home = tempdir().expect("tempdir"); - write_auth_json( - auth_home.path(), - chatgpt_auth_json_with_mode( - "enterprise", - Some("user-12345"), - Some("account-12345"), - "test-access-token", - "test-refresh-token", - "2025-01-01T00:00:00Z", - Some("chatgptAuthTokens"), - ), - ) - .expect("write auth"); let auth_manager = Arc::new( AuthManager::new( auth_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, + AuthCredentialsStoreMode::Ephemeral, /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), @@ -882,11 +884,32 @@ async fn get_bundle_unauthorized_without_recovery_uses_generic_message() { ) .await, ); + let initial_auth = CodexAuth::from_external_chatgpt_tokens( + &fake_chatgpt_jwt("enterprise", Some("user-12345"), b"initial"), + "account-12345", + Some("enterprise"), + ) + .expect("initial external auth"); + let refreshed_token = fake_chatgpt_jwt("enterprise", Some("user-12345"), b"refreshed"); + let refreshed_auth = CodexAuth::from_external_chatgpt_tokens( + &refreshed_token, + "account-12345", + Some("enterprise"), + ) + .expect("refreshed external auth"); + let external_auth = Arc::new(TestExternalChatgptAuth { + current: RwLock::new(initial_auth), + refreshed: refreshed_auth, + refresh_count: AtomicUsize::new(0), + }); + auth_manager + .set_external_auth(external_auth.clone()) + .await + .expect("set external auth"); - let fetcher = Arc::new(UnauthorizedBundleClient { - message: - "GET https://chatgpt.com/backend-api/wham/config/bundle failed: 401; content-type=text/html; body=nope" - .to_string(), + let fetcher = Arc::new(TokenBundleClient { + expected_token: refreshed_token, + bundle: test_bundle(), request_count: AtomicUsize::new(0), }); let codex_home = tempdir().expect("tempdir"); @@ -897,19 +920,9 @@ async fn get_bundle_unauthorized_without_recovery_uses_generic_message() { CLOUD_CONFIG_BUNDLE_TIMEOUT, ); - let err = service - .load_startup_bundle() - .await - .expect_err("cloud config bundle should fail closed"); - assert_eq!( - err, - CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::Auth, - Some(401), - CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE, - ) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); + assert_eq!(external_auth.refresh_count.load(Ordering::SeqCst), 1); } #[tokio::test] @@ -1037,6 +1050,7 @@ fn bundle_response_conversion_preserves_fragment_order() { "model = \"low\"".to_string(), ), ])), + managed_layers: None, }))), requirements_toml: Some(Some(Box::new( codex_backend_client::DeliveredRequirementsToml { @@ -1045,6 +1059,7 @@ fn bundle_response_conversion_preserves_fragment_order() { "High requirements".to_string(), "allowed_approval_policies = [\"never\"]".to_string(), )])), + managed_layers: None, }, ))), }; diff --git a/codex-rs/cloud-tasks/Cargo.toml b/codex-rs/cloud-tasks/Cargo.toml index e7997259a5..d842a95a94 100644 --- a/codex-rs/cloud-tasks/Cargo.toml +++ b/codex-rs/cloud-tasks/Cargo.toml @@ -16,7 +16,7 @@ workspace = true anyhow = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } -codex-client = { workspace = true } +codex-http-client = { workspace = true } codex-cloud-tasks-client = { workspace = true } # TODO: codex-cloud-tasks-mock-client should be in dev-dependencies. codex-cloud-tasks-mock-client = { workspace = true } diff --git a/codex-rs/cloud-tasks/src/env_detect.rs b/codex-rs/cloud-tasks/src/env_detect.rs index cd38c7f347..6137f27ea8 100644 --- a/codex-rs/cloud-tasks/src/env_detect.rs +++ b/codex-rs/cloud-tasks/src/env_detect.rs @@ -1,4 +1,4 @@ -use codex_client::build_reqwest_client_with_custom_ca; +use codex_http_client::build_reqwest_client_with_custom_ca; use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; use std::collections::HashMap; diff --git a/codex-rs/code-mode-host/Cargo.toml b/codex-rs/code-mode-host/Cargo.toml index 90b043b097..86a36d2a27 100644 --- a/codex-rs/code-mode-host/Cargo.toml +++ b/codex-rs/code-mode-host/Cargo.toml @@ -28,3 +28,4 @@ codex-protocol = { workspace = true } codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } serde_json = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/code-mode-host/tests/stdio.rs b/codex-rs/code-mode-host/tests/stdio.rs index e7b7506163..8edcbcaa8d 100644 --- a/codex-rs/code-mode-host/tests/stdio.rs +++ b/codex-rs/code-mode-host/tests/stdio.rs @@ -504,13 +504,17 @@ return; next_callback_event(&mut events_rx).await, CallbackEvent::Started("tool_call_slow".to_string()) ); - assert_eq!( + let mut closure_events = vec![ next_callback_event(&mut events_rx).await, - CallbackEvent::Cancelled("tool_call_slow".to_string()) - ); - assert_eq!( next_callback_event(&mut events_rx).await, - CallbackEvent::CellClosed(running_cell_id.clone()) + ]; + closure_events.sort_by(|left, right| format!("{left:?}").cmp(&format!("{right:?}"))); + assert_eq!( + closure_events, + vec![ + CallbackEvent::Cancelled("tool_call_slow".to_string()), + CallbackEvent::CellClosed(running_cell_id.clone()), + ] ); assert_eq!( wait_task @@ -677,12 +681,9 @@ async fn oversized_initial_response_does_not_close_the_shared_host() { async fn child_process_loss_cleans_up_and_rebuilds_the_shared_host() { let host_program = codex_utils_cargo_bin::cargo_bin("codex-code-mode-host").expect("host binary"); - let proxy_dir = - std::env::temp_dir().join(format!("codex-code-mode-host-loss-{}", std::process::id())); - let proxy_program = proxy_dir.join("host-proxy.sh"); - let pid_path = proxy_dir.join("host.pid"); - let _ = std::fs::remove_dir_all(&proxy_dir); - std::fs::create_dir_all(&proxy_dir).expect("create host proxy directory"); + let proxy_dir = tempfile::tempdir().expect("create host proxy directory"); + let proxy_program = proxy_dir.path().join("host-proxy.sh"); + let pid_path = proxy_dir.path().join("host.pid"); std::fs::write( &proxy_program, format!( @@ -869,6 +870,4 @@ async fn child_process_loss_cleans_up_and_rebuilds_the_shared_host() { events_a.try_recv(), Err(mpsc::error::TryRecvError::Empty) )); - - std::fs::remove_dir_all(proxy_dir).expect("remove host proxy directory"); } diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs index 653d7b1608..6d332bf0c4 100644 --- a/codex-rs/code-mode/src/lib.rs +++ b/codex-rs/code-mode/src/lib.rs @@ -3,6 +3,7 @@ mod remote_session; mod runtime; mod service; mod session_runtime; +mod v8_init; pub(crate) type TaskFailureHandler = std::sync::Arc; @@ -12,3 +13,5 @@ pub use remote_session::ProcessOwnedCodeModeSessionProvider; pub use service::InProcessCodeModeSession; pub use service::InProcessCodeModeSessionProvider; pub use service::NoopCodeModeSessionDelegate; +pub use v8_init::V8JitMode; +pub use v8_init::initialize_v8; diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs index 0b553123f7..0d32858724 100644 --- a/codex-rs/code-mode/src/runtime/mod.rs +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -7,7 +7,6 @@ mod value; use std::collections::HashMap; use std::panic::AssertUnwindSafe; use std::panic::catch_unwind; -use std::sync::OnceLock; use std::sync::mpsc as std_mpsc; use std::thread; @@ -21,6 +20,7 @@ use serde_json::Value as JsonValue; use tokio::sync::mpsc; use crate::TaskFailureHandler; +use crate::v8_init::ensure_v8_initialized; const EXIT_SENTINEL: &str = "__codex_code_mode_exit__"; @@ -84,7 +84,7 @@ pub(crate) fn spawn_runtime( ), String, > { - initialize_v8()?; + ensure_v8_initialized()?; let (command_tx, command_rx) = std_mpsc::channel(); let (control_tx, control_rx) = std_mpsc::channel(); @@ -165,22 +165,6 @@ pub(super) enum CompletionState { }, } -fn initialize_v8() -> Result<(), String> { - static PLATFORM: OnceLock, String>> = OnceLock::new(); - - match PLATFORM.get_or_init(|| { - v8::icu::set_common_data_77(deno_core_icudata::ICU_DATA) - .map_err(|error_code| format!("failed to initialize ICU data: {error_code}"))?; - let platform = v8::new_default_platform(0, false).make_shared(); - v8::V8::initialize_platform(platform.clone()); - v8::V8::initialize(); - Ok(platform) - }) { - Ok(_) => Ok(()), - Err(error_text) => Err(error_text.clone()), - } -} - fn run_runtime( config: RuntimeConfig, event_tx: mpsc::UnboundedSender, diff --git a/codex-rs/code-mode/src/v8_init.rs b/codex-rs/code-mode/src/v8_init.rs new file mode 100644 index 0000000000..0d00d9fad5 --- /dev/null +++ b/codex-rs/code-mode/src/v8_init.rs @@ -0,0 +1,65 @@ +use std::sync::OnceLock; + +/// Controls whether V8 may generate executable code at runtime. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum V8JitMode { + #[default] + Enabled, + Disabled, +} + +struct V8Initialization { + _platform: v8::SharedRef, + jit_mode: V8JitMode, +} + +static V8_INITIALIZATION: OnceLock> = OnceLock::new(); + +/// Initializes the process-wide V8 platform with the requested JIT mode. +/// +/// Call this before executing any code-mode cells when JIT must be disabled. +/// V8 cannot change JIT mode after initialization, so a later call requesting +/// a different mode returns an error. Code mode initializes V8 with JIT enabled +/// by default when this function has not been called explicitly. +pub fn initialize_v8(jit_mode: V8JitMode) -> Result<(), String> { + match V8_INITIALIZATION.get_or_init(|| initialize_v8_with_mode(jit_mode)) { + Ok(initialization) if initialization.jit_mode == jit_mode => Ok(()), + Ok(initialization) => Err(format!( + "V8 was already initialized with JIT {}", + initialization.jit_mode.description() + )), + Err(error_text) => Err(error_text.clone()), + } +} + +pub(crate) fn ensure_v8_initialized() -> Result<(), String> { + match V8_INITIALIZATION.get_or_init(|| initialize_v8_with_mode(V8JitMode::Enabled)) { + Ok(_) => Ok(()), + Err(error_text) => Err(error_text.clone()), + } +} + +fn initialize_v8_with_mode(jit_mode: V8JitMode) -> Result { + v8::icu::set_common_data_77(deno_core_icudata::ICU_DATA) + .map_err(|error_code| format!("failed to initialize ICU data: {error_code}"))?; + match jit_mode { + V8JitMode::Enabled => {} + V8JitMode::Disabled => v8::V8::set_flags_from_string("--jitless"), + } + let platform = v8::new_default_platform(0, false).make_shared(); + v8::V8::initialize_platform(platform.clone()); + v8::V8::initialize(); + Ok(V8Initialization { + _platform: platform, + jit_mode, + }) +} + +impl V8JitMode { + fn description(self) -> &'static str { + match self { + Self::Enabled => "enabled", + Self::Disabled => "disabled", + } + } +} diff --git a/codex-rs/code-mode/tests/jit.rs b/codex-rs/code-mode/tests/jit.rs new file mode 100644 index 0000000000..0f8e866c47 --- /dev/null +++ b/codex-rs/code-mode/tests/jit.rs @@ -0,0 +1,41 @@ +use codex_code_mode::ExecuteRequest; +use codex_code_mode::InProcessCodeModeSession; +use codex_code_mode::RuntimeResponse; +use codex_code_mode::V8JitMode; +use codex_code_mode::initialize_v8; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn code_mode_runs_with_jit_disabled() { + initialize_v8(V8JitMode::Disabled).expect("initialize V8 without JIT"); + + let service = InProcessCodeModeSession::new(); + let started = service + .execute(ExecuteRequest { + tool_call_id: "call_1".to_string(), + enabled_tools: Vec::new(), + source: "21 * 2;".to_string(), + yield_time_ms: None, + max_output_tokens: None, + }) + .await + .expect("start code-mode cell"); + let cell_id = started.cell_id.clone(); + let response = started + .initial_response() + .await + .expect("execute code-mode cell"); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id, + content_items: Vec::new(), + error_text: None, + } + ); + assert_eq!( + initialize_v8(V8JitMode::Enabled), + Err("V8 was already initialized with JIT disabled".to_string()) + ); +} diff --git a/codex-rs/codex-api/Cargo.toml b/codex-rs/codex-api/Cargo.toml index 72aa66a810..084b058e12 100644 --- a/codex-rs/codex-api/Cargo.toml +++ b/codex-rs/codex-api/Cargo.toml @@ -10,6 +10,7 @@ base64 = { workspace = true } bytes = { workspace = true } chrono = { workspace = true } codex-client = { workspace = true } +codex-http-client = { workspace = true } codex-protocol = { workspace = true } codex-utils-rustls-provider = { workspace = true } futures = { workspace = true } diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index f00cd742e6..e4600e26aa 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -104,6 +104,11 @@ pub enum ResponseEvent { delta: String, summary_index: i64, }, + ReasoningSummaryDone { + item_id: String, + text: String, + summary_index: i64, + }, ReasoningContentDelta { delta: String, content_index: i64, @@ -121,6 +126,7 @@ pub struct SafetyBuffering { pub reasons: Vec, #[serde(skip)] pub show_buffering_ui: bool, + #[serde(rename = "retry_model")] pub faster_model: Option, } @@ -147,6 +153,17 @@ pub struct Reasoning { pub context: Option, } +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ReasoningSummaryDelivery { + SequentialCutoff, +} + +#[derive(Debug, Serialize, Clone, PartialEq)] +pub struct StreamOptions { + pub reasoning_summary_delivery: ReasoningSummaryDelivery, +} + #[derive(Debug, Serialize, Default, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub enum TextFormatType { @@ -208,6 +225,8 @@ pub struct ResponsesApiRequest { pub reasoning: Option, pub store: bool, pub stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_options: Option, pub include: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub service_tier: Option, @@ -232,6 +251,7 @@ impl From<&ResponsesApiRequest> for ResponseCreateWsRequest { reasoning: request.reasoning.clone(), store: request.store, stream: request.stream, + stream_options: request.stream_options.clone(), include: request.include.clone(), service_tier: request.service_tier.clone(), prompt_cache_key: request.prompt_cache_key.clone(), @@ -257,6 +277,8 @@ pub struct ResponseCreateWsRequest { pub reasoning: Option, pub store: bool, pub stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_options: Option, pub include: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub service_tier: Option, diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index 499635579f..7b1a74e4ae 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -16,7 +16,7 @@ use crate::endpoint::realtime_websocket::protocol::parse_realtime_event; use crate::error::ApiError; use crate::provider::Provider; use codex_client::backoff; -use codex_client::maybe_build_rustls_client_config_with_custom_ca; +use codex_http_client::maybe_build_rustls_client_config_with_custom_ca; use codex_protocol::protocol::ConversationTextRole; use codex_protocol::protocol::RealtimeTranscriptDelta; use codex_utils_rustls_provider::ensure_rustls_crypto_provider; @@ -391,6 +391,13 @@ impl RealtimeWebsocketWriter { } impl RealtimeWebsocketEvents { + pub async fn take_transcript_tail(&self) -> Vec { + let mut active_transcript = self.active_transcript.lock().await; + let tail = active_transcript.entries[active_transcript.last_handoff_entry_count..].to_vec(); + active_transcript.last_handoff_entry_count = active_transcript.entries.len(); + tail + } + pub async fn next_event(&self) -> Result, ApiError> { if self.is_closed.load(Ordering::SeqCst) { return Ok(None); @@ -962,6 +969,56 @@ mod tests { ); } + #[tokio::test] + async fn takes_only_transcript_after_last_handoff_once() { + let (_tx_message, rx_message) = async_channel::unbounded(); + let events = RealtimeWebsocketEvents { + rx_message, + active_transcript: Arc::new(Mutex::new(ActiveTranscriptState::default())), + event_parser: RealtimeEventParser::V1, + is_closed: Arc::new(AtomicBool::new(false)), + }; + + assert_eq!(events.take_transcript_tail().await, vec![]); + + let mut covered = RealtimeEvent::InputTranscriptDelta(RealtimeTranscriptDelta { + delta: "already handed off".to_string(), + }); + events.update_active_transcript(&mut covered).await; + let mut handoff = RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id: "handoff_1".to_string(), + item_id: "item_1".to_string(), + input_transcript: "already handed off".to_string(), + active_transcript: vec![], + }); + events.update_active_transcript(&mut handoff).await; + assert_eq!( + handoff, + RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id: "handoff_1".to_string(), + item_id: "item_1".to_string(), + input_transcript: "already handed off".to_string(), + active_transcript: vec![RealtimeTranscriptEntry { + role: "user".to_string(), + text: "already handed off".to_string(), + }], + }) + ); + + let mut tail = RealtimeEvent::OutputTranscriptDelta(RealtimeTranscriptDelta { + delta: "tail".to_string(), + }); + events.update_active_transcript(&mut tail).await; + assert_eq!( + events.take_transcript_tail().await, + vec![RealtimeTranscriptEntry { + role: "assistant".to_string(), + text: "tail".to_string(), + }] + ); + assert_eq!(events.take_transcript_tail().await, vec![]); + } + #[test] fn parse_input_transcript_delta_event() { let payload = json!({ diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index c4c617ac99..cce056f638 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -11,7 +11,7 @@ use crate::sse::ResponsesStreamEvent; use crate::sse::process_responses_event; use crate::telemetry::WebsocketTelemetry; use codex_client::TransportError; -use codex_client::maybe_build_rustls_client_config_with_custom_ca; +use codex_http_client::maybe_build_rustls_client_config_with_custom_ca; use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use futures::SinkExt; use futures::StreamExt; @@ -851,6 +851,7 @@ mod tests { reasoning: None, store: false, stream: true, + stream_options: None, include: vec!["reasoning.encrypted_content".to_string()], service_tier: Some("priority".to_string()), prompt_cache_key: Some("cache-key".to_string()), @@ -1061,7 +1062,7 @@ mod tests { "safety_buffering": { "use_cases": ["cyber"], "reasons": ["user_risk"], - "faster_model": "gpt-fast-wire" + "retry_model": "gpt-fast-wire" } })) .expect("deserialize safety buffering event"); diff --git a/codex-rs/codex-api/src/files.rs b/codex-rs/codex-api/src/files.rs index 8bcf1a4d0d..3cd3932d06 100644 --- a/codex-rs/codex-api/src/files.rs +++ b/codex-rs/codex-api/src/files.rs @@ -2,7 +2,7 @@ use std::time::Duration; use crate::AuthProvider; use bytes::Bytes; -use codex_client::build_reqwest_client_with_custom_ca; +use codex_http_client::build_reqwest_client_with_custom_ca; use futures::Stream; use reqwest::StatusCode; use reqwest::header::CONTENT_LENGTH; diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 72de4d0585..1d1a4c8c6e 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -34,11 +34,13 @@ pub use crate::common::RawMemory; pub use crate::common::RawMemoryMetadata; pub use crate::common::Reasoning; pub use crate::common::ReasoningContext; +pub use crate::common::ReasoningSummaryDelivery; pub use crate::common::ResponseCreateWsRequest; pub use crate::common::ResponseEvent; pub use crate::common::ResponseStream; pub use crate::common::ResponsesApiRequest; pub use crate::common::ResponsesWsRequest; +pub use crate::common::StreamOptions; pub use crate::common::TextControls; pub use crate::common::WS_REQUEST_HEADER_TRACEPARENT_CLIENT_METADATA_KEY; pub use crate::common::WS_REQUEST_HEADER_TRACESTATE_CLIENT_METADATA_KEY; diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index bd742dfa14..70f96cb855 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -167,6 +167,7 @@ pub struct ResponsesStreamEvent { item_id: Option, call_id: Option, delta: Option, + text: Option, summary_index: Option, content_index: Option, safety_buffering: Option, @@ -236,10 +237,10 @@ impl ResponsesStreamEvent { treatment: &SafetyBufferingTreatment, ) -> Option { let value = self.safety_buffering.as_ref()?; - let faster_model_present = value.as_object()?.contains_key("faster_model"); + let retry_model_present = value.as_object()?.contains_key("retry_model"); let mut buffering: SafetyBuffering = serde_json::from_value(value.clone()).ok()?; buffering.show_buffering_ui = true; - if !faster_model_present { + if !retry_model_present { buffering.faster_model.clone_from(&treatment.faster_model); } Some(buffering) @@ -358,6 +359,17 @@ pub fn process_responses_event( })); } } + "response.reasoning_summary_text.done" => { + if let (Some(item_id), Some(text), Some(summary_index)) = + (event.item_id, event.text, event.summary_index) + { + return Ok(Some(ResponseEvent::ReasoningSummaryDone { + item_id, + text, + summary_index, + })); + } + } "response.reasoning_text.delta" => { if let (Some(delta), Some(content_index)) = (event.delta, event.content_index) { return Ok(Some(ResponseEvent::ReasoningContentDelta { @@ -386,7 +398,8 @@ pub fn process_responses_event( } else if is_cyber_policy_error(&error) { let message = cyber_policy_message(error.message); response_error = ApiError::CyberPolicy { message }; - } else if is_invalid_prompt_error(&error) { + } else if matches!(error.code.as_deref(), Some("invalid_prompt" | "bio_policy")) + { let message = error .message .unwrap_or_else(|| "Invalid request.".to_string()); @@ -620,10 +633,6 @@ fn is_usage_not_included(error: &Error) -> bool { error.code.as_deref() == Some("usage_not_included") } -fn is_invalid_prompt_error(error: &Error) -> bool { - error.code.as_deref() == Some("invalid_prompt") -} - fn is_cyber_policy_error(error: &Error) -> bool { error.code.as_deref() == Some("cyber_policy") } @@ -796,6 +805,32 @@ mod tests { } } + #[tokio::test] + async fn parses_reasoning_summary_done() { + let events = run_sse(vec![ + json!({ + "type": "response.reasoning_summary_text.done", + "item_id": "reasoning-1", + "summary_index": 0, + "text": "Checking", + }), + json!({ + "type": "response.completed", + "response": { "id": "resp1" }, + }), + ]) + .await; + + assert_matches!( + &events[0], + ResponseEvent::ReasoningSummaryDone { + item_id, + text, + summary_index: 0, + } if item_id == "reasoning-1" && text == "Checking" + ); + } + #[tokio::test] async fn error_when_missing_completed() { let item1 = json!({ @@ -1038,23 +1073,42 @@ mod tests { } #[tokio::test] - async fn invalid_prompt_without_type_is_invalid_request() { - let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_invalid_prompt_no_type","object":"response","created_at":1759771628,"status":"failed","background":false,"error":{"code":"invalid_prompt","message":"Invalid prompt: we've limited access to this content for safety reasons."},"incomplete_details":null}}"#; + async fn content_policy_errors_without_type_are_invalid_requests() { + for (code, expected_message) in [ + ( + "invalid_prompt", + "Invalid prompt: we've limited access to this content for safety reasons.", + ), + ( + "bio_policy", + "This content was flagged for possible biological risk.", + ), + ] { + let raw_error = json!({ + "type": "response.failed", + "sequence_number": 3, + "response": { + "id": "resp_content_policy_no_type", + "object": "response", + "created_at": 1759771628, + "status": "failed", + "background": false, + "error": { "code": code, "message": expected_message }, + "incomplete_details": null, + }, + }) + .to_string(); + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); - let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + let events = collect_events(&[sse1.as_bytes()]).await; - let events = collect_events(&[sse1.as_bytes()]).await; - - assert_eq!(events.len(), 1); - - match &events[0] { - Err(ApiError::InvalidRequest { message }) => { - assert_eq!( - message, - "Invalid prompt: we've limited access to this content for safety reasons." - ); + assert_eq!(events.len(), 1); + match &events[0] { + Err(ApiError::InvalidRequest { message }) => { + assert_eq!(message, expected_message); + } + other => panic!("unexpected event for {code}: {other:?}"), } - other => panic!("unexpected event: {other:?}"), } } @@ -1363,7 +1417,7 @@ mod tests { "safety_buffering": { "use_cases": ["cyber"], "reasons": ["user_risk"], - "faster_model": "gpt-fast-wire" + "retry_model": "gpt-fast-wire" } }), json!({ @@ -1411,12 +1465,12 @@ mod tests { } #[test] - fn safety_buffering_prefers_wire_faster_model_and_only_falls_back_when_omitted() { + fn safety_buffering_prefers_wire_retry_model_and_only_falls_back_when_omitted() { let treatment = SafetyBufferingTreatment { faster_model: Some("gpt-fast-header".to_string()), }; - for (faster_model, expected_faster_model) in [ + for (retry_model, expected_faster_model) in [ (None, Some("gpt-fast-header")), (Some(Value::Null), None), (Some(json!("gpt-fast-wire")), Some("gpt-fast-wire")), @@ -1428,8 +1482,8 @@ mod tests { "reasons": ["user_risk"] } }); - if let Some(faster_model) = faster_model { - event["safety_buffering"]["faster_model"] = faster_model; + if let Some(retry_model) = retry_model { + event["safety_buffering"]["retry_model"] = retry_model; } let event: ResponsesStreamEvent = serde_json::from_value(event).expect("deserialize safety buffering event"); diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 60a9852cfa..ab4ab1d08f 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -321,6 +321,7 @@ async fn responses_client_stream_request_preserves_item_ids() -> Result<()> { reasoning: None, store: false, stream: true, + stream_options: None, include: Vec::new(), service_tier: None, prompt_cache_key: None, @@ -407,6 +408,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> { reasoning: None, store: false, stream: true, + stream_options: None, include: Vec::new(), service_tier: None, prompt_cache_key: None, @@ -526,6 +528,7 @@ async fn azure_store_sends_ids_and_headers() -> Result<()> { reasoning: None, store: true, stream: true, + stream_options: None, include: Vec::new(), service_tier: None, prompt_cache_key: None, diff --git a/codex-rs/codex-backend-openapi-models/src/models/delivered_config_toml.rs b/codex-rs/codex-backend-openapi-models/src/models/delivered_config_toml.rs index 0183eced47..081fe4bd9c 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/delivered_config_toml.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/delivered_config_toml.rs @@ -21,12 +21,20 @@ pub struct DeliveredConfigToml { skip_serializing_if = "Option::is_none" )] pub enterprise_managed: Option>>, + #[serde( + rename = "managed_layers", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub managed_layers: Option>>, } impl DeliveredConfigToml { pub fn new() -> DeliveredConfigToml { DeliveredConfigToml { enterprise_managed: None, + managed_layers: None, } } } diff --git a/codex-rs/codex-backend-openapi-models/src/models/delivered_managed_layers.rs b/codex-rs/codex-backend-openapi-models/src/models/delivered_managed_layers.rs new file mode 100644 index 0000000000..043c10b883 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/delivered_managed_layers.rs @@ -0,0 +1,33 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct DeliveredManagedLayers { + #[serde(rename = "baseline")] + pub baseline: Vec, + #[serde(rename = "system_overlay")] + pub system_overlay: Vec, +} + +impl DeliveredManagedLayers { + pub fn new( + baseline: Vec, + system_overlay: Vec, + ) -> DeliveredManagedLayers { + DeliveredManagedLayers { + baseline, + system_overlay, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/delivered_requirements_toml.rs b/codex-rs/codex-backend-openapi-models/src/models/delivered_requirements_toml.rs index 953087d734..bec357e187 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/delivered_requirements_toml.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/delivered_requirements_toml.rs @@ -21,12 +21,20 @@ pub struct DeliveredRequirementsToml { skip_serializing_if = "Option::is_none" )] pub enterprise_managed: Option>>, + #[serde( + rename = "managed_layers", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub managed_layers: Option>>, } impl DeliveredRequirementsToml { pub fn new() -> DeliveredRequirementsToml { DeliveredRequirementsToml { enterprise_managed: None, + managed_layers: None, } } } diff --git a/codex-rs/codex-backend-openapi-models/src/models/mod.rs b/codex-rs/codex-backend-openapi-models/src/models/mod.rs index c3f4e6beec..b154a044fd 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/mod.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/mod.rs @@ -13,6 +13,9 @@ pub use self::config_file_response::ConfigFileResponse; pub(crate) mod delivered_config_toml; pub use self::delivered_config_toml::DeliveredConfigToml; +pub(crate) mod delivered_managed_layers; +pub use self::delivered_managed_layers::DeliveredManagedLayers; + pub(crate) mod delivered_requirements_toml; pub use self::delivered_requirements_toml::DeliveredRequirementsToml; diff --git a/codex-rs/codex-client/BUILD.bazel b/codex-rs/codex-client/BUILD.bazel index 3d26819111..dd7e504634 100644 --- a/codex-rs/codex-client/BUILD.bazel +++ b/codex-rs/codex-client/BUILD.bazel @@ -2,6 +2,5 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "codex-client", - compile_data = glob(["tests/fixtures/**"]), crate_name = "codex_client", ) diff --git a/codex-rs/codex-client/Cargo.toml b/codex-rs/codex-client/Cargo.toml index fcd97b6bda..8e354ebeea 100644 --- a/codex-rs/codex-client/Cargo.toml +++ b/codex-rs/codex-client/Cargo.toml @@ -5,47 +5,16 @@ name = "codex-client" version.workspace = true [dependencies] -bytes = { workspace = true } +codex-http-client = { workspace = true } eventsource-stream = { workspace = true } futures = { workspace = true } http = { workspace = true } -opentelemetry = { workspace = true } rand = { workspace = true } -reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots", "stream"] } -rustls = { workspace = true } -rustls-native-certs = { workspace = true } -rustls-pki-types = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -thiserror = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "time", "sync"] } -tracing = { workspace = true } -tracing-opentelemetry = { workspace = true } -codex-utils-rustls-provider = { workspace = true } -zstd = { workspace = true } - -[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] -sha2 = { workspace = true } - -[target.'cfg(target_os = "macos")'.dependencies] -system-configuration = { workspace = true } - -[target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { version = "0.52", features = [ - "Win32_Foundation", - "Win32_Networking_WinHttp", -] } [lints] workspace = true -[dev-dependencies] -codex-utils-cargo-bin = { workspace = true } -opentelemetry_sdk = { workspace = true } -pretty_assertions = { workspace = true } -rcgen = { workspace = true } -tempfile = { workspace = true } -tracing-subscriber = { workspace = true } - [lib] doctest = false +test = false diff --git a/codex-rs/codex-client/README.md b/codex-rs/codex-client/README.md index 045ee7b343..1e4073117a 100644 --- a/codex-rs/codex-client/README.md +++ b/codex-rs/codex-client/README.md @@ -1,8 +1,8 @@ # codex-client -Generic transport layer that wraps HTTP requests, retries, and streaming primitives without any Codex/OpenAI awareness. +Higher-level request policy layered on `codex-http-client` without any Codex/OpenAI API awareness. -- Defines `HttpTransport` and a default `ReqwestTransport` plus thin `Request`/`Response` types. - Provides retry utilities (`RetryPolicy`, `RetryOn`, `run_with_retry`, `backoff`) that callers plug into for unary and streaming calls. - Supplies the `sse_stream` helper to turn byte streams into raw SSE `data:` frames with idle timeouts and surfaced stream errors. -- Consumed by higher-level crates like `codex-api`; it stays neutral on endpoints, headers, or API-specific error shapes. +- Defines the request telemetry callback used by higher-level clients. +- Re-exports the low-level HTTP types temporarily so consumers can migrate to `codex-http-client` incrementally. diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index 78daa6c288..4b83ba0dd0 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -1,49 +1,13 @@ -mod chatgpt_cloudflare_cookies; -mod chatgpt_hosts; -mod custom_ca; -mod default_client; -mod error; -mod outbound_proxy; -mod request; mod retry; mod sse; mod telemetry; -mod transport; -pub use crate::chatgpt_cloudflare_cookies::with_chatgpt_cloudflare_cookie_store; -pub use crate::chatgpt_hosts::is_allowed_chatgpt_host; -pub use crate::custom_ca::BuildCustomCaTransportError; -/// Test-only subprocess hook for custom CA coverage. -/// -/// This stays public only so the `custom_ca_probe` binary target can reuse the shared helper. It -/// is hidden from normal docs because ordinary callers should use -/// [`build_reqwest_client_with_custom_ca`] instead. -#[doc(hidden)] -pub use crate::custom_ca::build_reqwest_client_for_subprocess_tests; -pub use crate::custom_ca::build_reqwest_client_with_custom_ca; -pub use crate::custom_ca::maybe_build_rustls_client_config_with_custom_ca; -pub use crate::default_client::CodexHttpClient; -pub use crate::default_client::CodexRequestBuilder; -pub use crate::error::StreamError; -pub use crate::error::TransportError; -pub use crate::outbound_proxy::BuildRouteAwareHttpClientError; -pub use crate::outbound_proxy::ClientRouteClass; -pub use crate::outbound_proxy::OutboundProxyConfig; -pub use crate::outbound_proxy::RouteFailureClass; -pub use crate::outbound_proxy::build_reqwest_client_for_route; -pub use crate::request::EncodedJsonBody; -pub use crate::request::PreparedRequestBody; -pub use crate::request::Request; -pub use crate::request::RequestBody; -pub use crate::request::RequestCompression; -pub use crate::request::Response; pub use crate::retry::RetryOn; pub use crate::retry::RetryPolicy; pub use crate::retry::backoff; pub use crate::retry::run_with_retry; pub use crate::sse::sse_stream; pub use crate::telemetry::RequestTelemetry; -pub use crate::transport::ByteStream; -pub use crate::transport::HttpTransport; -pub use crate::transport::ReqwestTransport; -pub use crate::transport::StreamResponse; +pub use codex_http_client::HttpClient as CodexHttpClient; +pub use codex_http_client::RequestBuilder as CodexRequestBuilder; +pub use codex_http_client::*; diff --git a/codex-rs/codex-client/src/retry.rs b/codex-rs/codex-client/src/retry.rs index c7bdd34b1e..9eb408878b 100644 --- a/codex-rs/codex-client/src/retry.rs +++ b/codex-rs/codex-client/src/retry.rs @@ -1,5 +1,5 @@ -use crate::error::TransportError; -use crate::request::Request; +use codex_http_client::Request; +use codex_http_client::TransportError; use rand::Rng; use std::future::Future; use std::time::Duration; diff --git a/codex-rs/codex-client/src/sse.rs b/codex-rs/codex-client/src/sse.rs index f3aba3a2c5..ed1591f6de 100644 --- a/codex-rs/codex-client/src/sse.rs +++ b/codex-rs/codex-client/src/sse.rs @@ -1,5 +1,5 @@ -use crate::error::StreamError; -use crate::transport::ByteStream; +use codex_http_client::ByteStream; +use codex_http_client::StreamError; use eventsource_stream::Eventsource; use futures::StreamExt; use tokio::sync::mpsc; diff --git a/codex-rs/codex-client/src/telemetry.rs b/codex-rs/codex-client/src/telemetry.rs index 457d47f4fc..b856414d33 100644 --- a/codex-rs/codex-client/src/telemetry.rs +++ b/codex-rs/codex-client/src/telemetry.rs @@ -1,4 +1,4 @@ -use crate::error::TransportError; +use codex_http_client::TransportError; use http::StatusCode; use std::time::Duration; diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index c76e3c3c08..223d4296d5 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -23,8 +23,8 @@ use crate::elicitation::ElicitationReviewerHandle; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::ToolPluginProvenance; use crate::rmcp_client::AsyncManagedClient; +use crate::rmcp_client::CODEX_APPS_REFRESH_DURATION_METRIC; use crate::rmcp_client::DEFAULT_STARTUP_TIMEOUT; -use crate::rmcp_client::MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC; use crate::rmcp_client::MCP_TOOLS_LIST_DURATION_METRIC; use crate::rmcp_client::ManagedClient; use crate::rmcp_client::StartupOutcomeError; @@ -142,6 +142,7 @@ impl McpConnectionManager { tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, elicitation_reviewer: Option, + elicitation_lifecycle: Option, elicitation_router: ElicitationRequestRouter, ) -> Self { let mut required_servers = mcp_servers @@ -157,6 +158,7 @@ impl McpConnectionManager { approval_policy.value(), initial_permission_profile, elicitation_reviewer, + elicitation_lifecycle, elicitation_router, ); let tool_plugin_provenance = Arc::new(tool_plugin_provenance); @@ -360,6 +362,7 @@ impl McpConnectionManager { approval_policy.value(), permission_profile.clone(), /*reviewer*/ None, + /*lifecycle*/ None, ElicitationRequestRouter::default(), ), startup_cancellation_token: CancellationToken::new(), @@ -527,6 +530,7 @@ impl McpConnectionManager { /// cache is enabled and the latest filtered tools are returned directly to /// the caller. On failure, existing shared cache contents remain unchanged. pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result> { + let refresh_start = Instant::now(); let managed_client = self .clients .get(CODEX_APPS_MCP_SERVER_NAME) @@ -536,7 +540,6 @@ impl McpConnectionManager { .context("failed to get client")?; let list_start = Instant::now(); - let fetch_start = Instant::now(); let fetch_ticket = managed_client .codex_apps_tools_cache_context .as_ref() @@ -544,6 +547,7 @@ impl McpConnectionManager { let tools = list_tools_for_client_uncached( CODEX_APPS_MCP_SERVER_NAME, /*is_codex_apps_mcp_server*/ true, + /*codex_apps_refresh_trigger*/ "explicit", &managed_client.client, managed_client.tool_timeout, managed_client.server_instructions.as_deref(), @@ -552,11 +556,6 @@ impl McpConnectionManager { .with_context(|| { format!("failed to refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}'") })?; - emit_duration( - MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, - fetch_start.elapsed(), - &[], - ); let tools = match ( @@ -579,10 +578,13 @@ impl McpConnectionManager { tool.tool = tool_with_model_visible_input_schema(&tool.tool); self.with_server_metadata(tool) }); - Ok(normalize_tools_for_model_with_prefix( - tools, - self.prefix_mcp_tool_names, - )) + let tools = normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names); + emit_duration( + CODEX_APPS_REFRESH_DURATION_METRIC, + refresh_start.elapsed(), + &[("path", "legacy"), ("trigger", "explicit")], + ); + Ok(tools) } /// Returns resources from servers selected by `include_server`. Each key diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 1f61ab700e..41fc19a594 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::codex_apps_cache::CodexAppsToolsCache; use crate::codex_apps_cache::CodexAppsToolsCacheContext; use crate::declared_openai_file_input_param_names; +use crate::elicitation::ElicitationLifecycle; use crate::elicitation::ElicitationRequestManager; use crate::elicitation::ElicitationRequestRouter; use crate::elicitation::elicitation_is_rejected_by_policy; @@ -323,6 +324,7 @@ async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() { AskForApproval::Never, PermissionProfile::Disabled, /*reviewer*/ None, + /*lifecycle*/ None, ElicitationRequestRouter::default(), ); let (tx_event, _rx_event) = async_channel::bounded(1); @@ -359,6 +361,7 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel AskForApproval::Never, PermissionProfile::Disabled, /*reviewer*/ None, + /*lifecycle*/ None, ElicitationRequestRouter::default(), ); let (tx_event, _rx_event) = async_channel::bounded(1); @@ -395,17 +398,35 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel #[tokio::test] async fn shared_elicitation_router_targets_the_exact_pending_request() { + struct Registration(Arc); + + impl Drop for Registration { + fn drop(&mut self) { + self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + } + } + let router = ElicitationRequestRouter::default(); + let outstanding = Arc::new(AtomicUsize::new(0)); + let lifecycle = ElicitationLifecycle::new({ + let outstanding = outstanding.clone(); + move || { + outstanding.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Registration(outstanding.clone()) + } + }); let manager_a = ElicitationRequestManager::new( AskForApproval::OnRequest, PermissionProfile::default(), /*reviewer*/ None, + Some(lifecycle.clone()), router.clone(), ); let manager_b = ElicitationRequestManager::new( AskForApproval::OnRequest, PermissionProfile::default(), /*reviewer*/ None, + Some(lifecycle), router, ); let (tx_event, rx_event) = async_channel::bounded(2); @@ -435,6 +456,7 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() { else { panic!("expected elicitation request"); }; + assert_eq!(outstanding.load(std::sync::atomic::Ordering::SeqCst), 2); let ( codex_protocol::mcp::RequestId::String(request_a_id), codex_protocol::mcp::RequestId::String(request_b_id), @@ -485,6 +507,7 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() { .expect("request B response"), response_b ); + assert_eq!(outstanding.load(std::sync::atomic::Ordering::SeqCst), 0); } #[test] @@ -1539,6 +1562,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { ToolPluginProvenance::default(), /*auth*/ None, /*elicitation_reviewer*/ None, + /*elicitation_lifecycle*/ None, ElicitationRequestRouter::default(), ) .await; diff --git a/codex-rs/codex-mcp/src/elicitation.rs b/codex-rs/codex-mcp/src/elicitation.rs index e4fcf49d22..b6bb7fa791 100644 --- a/codex-rs/codex-mcp/src/elicitation.rs +++ b/codex-rs/codex-mcp/src/elicitation.rs @@ -52,6 +52,33 @@ pub trait ElicitationReviewer: Send + Sync { pub type ElicitationReviewerHandle = Arc; +/// Holds an owner-provided registration while an MCP elicitation is waiting for a response. +#[derive(Clone)] +pub struct ElicitationLifecycle { + register: Arc Box + Send + Sync>, +} + +impl ElicitationLifecycle { + pub fn new(register: impl Fn() -> T + Send + Sync + 'static) -> Self + where + T: Send + Sync + 'static, + { + Self { + register: Arc::new(move || Box::new(register())), + } + } + + fn start(&self) -> ActiveElicitation { + ActiveElicitation { + _registration: (self.register)(), + } + } +} + +struct ActiveElicitation { + _registration: Box, +} + /// Routes model-visible elicitation response tokens to their exact pending responders. /// /// One router is shared by every MCP runtime created for a thread. The public response token is @@ -86,6 +113,7 @@ pub(crate) struct ElicitationRequestManager { pub(crate) permission_profile: Arc>, auto_deny: Arc>, reviewer: Option, + lifecycle: Option, } impl ElicitationRequestManager { @@ -93,6 +121,7 @@ impl ElicitationRequestManager { approval_policy: AskForApproval, permission_profile: PermissionProfile, reviewer: Option, + lifecycle: Option, router: ElicitationRequestRouter, ) -> Self { Self { @@ -101,6 +130,7 @@ impl ElicitationRequestManager { permission_profile: Arc::new(StdMutex::new(permission_profile)), auto_deny: Arc::new(StdMutex::new(false)), reviewer, + lifecycle, } } @@ -140,6 +170,7 @@ impl ElicitationRequestManager { let permission_profile = self.permission_profile.clone(); let auto_deny = self.auto_deny.clone(); let reviewer = self.reviewer.clone(); + let lifecycle = self.lifecycle.clone(); Box::new(move |id, elicitation| { let router = router.clone(); let tx_event = tx_event.clone(); @@ -148,6 +179,7 @@ impl ElicitationRequestManager { let permission_profile = permission_profile.clone(); let auto_deny = auto_deny.clone(); let reviewer = reviewer.clone(); + let lifecycle = lifecycle.clone(); async move { let auto_deny = auto_deny .lock() @@ -249,6 +281,7 @@ impl ElicitationRequestManager { }, }; let (tx, rx) = oneshot::channel(); + let _active_elicitation = lifecycle.as_ref().map(ElicitationLifecycle::start); { let mut lock = router.requests.lock().await; lock.insert((server_name.clone(), routed_request_id), tx); diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index eda7ac2399..77128d7970 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -1,5 +1,6 @@ pub use connection_manager::McpConnectionManager; pub use connection_manager::tool_is_model_visible; +pub use elicitation::ElicitationLifecycle; pub use elicitation::ElicitationRequestRouter; pub use elicitation::ElicitationReviewRequest; pub use elicitation::ElicitationReviewer; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index ffb797f26e..87ea25dd9f 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -339,6 +339,7 @@ pub async fn read_mcp_resource( tool_plugin_provenance(config), auth, /*elicitation_reviewer*/ None, + /*elicitation_lifecycle*/ None, crate::elicitation::ElicitationRequestRouter::default(), ) .await; @@ -416,6 +417,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( tool_plugin_provenance, auth, /*elicitation_reviewer*/ None, + /*elicitation_lifecycle*/ None, crate::elicitation::ElicitationRequestRouter::default(), ) .await; diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 37819203f6..6fe820b4fe 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -82,6 +82,7 @@ pub const OPENAI_FORM_CAPABILITY: &str = "openai/form"; pub(crate) const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms"; pub(crate) const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = "codex.mcp.tools.fetch_uncached.duration_ms"; +pub(crate) const CODEX_APPS_REFRESH_DURATION_METRIC: &str = "codex.apps.refresh.duration_ms"; pub(crate) const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(30); pub(crate) const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(300); @@ -304,6 +305,7 @@ impl ManagedClientStartup { .unwrap_or_default(); let cancel_token_for_fut = cancel_token; async move { + let refresh_start = is_codex_apps_mcp_server.then(Instant::now); let outcome = match async { if let Err(error) = validate_mcp_server_name(&server_name) { return Err(error.into()); @@ -349,6 +351,15 @@ impl ManagedClientStartup { Ok(result) => result, Err(CancelErr::Cancelled) => Err(StartupOutcomeError::Cancelled), }; + if outcome.is_ok() + && let Some(refresh_start) = refresh_start + { + emit_duration( + CODEX_APPS_REFRESH_DURATION_METRIC, + refresh_start.elapsed(), + &[("path", "legacy"), ("trigger", "initial")], + ); + } startup_complete.store(true, Ordering::Release); outcome @@ -564,10 +575,12 @@ impl From for StartupOutcomeError { pub(crate) async fn list_tools_for_client_uncached( server_name: &str, is_codex_apps_mcp_server: bool, + codex_apps_refresh_trigger: &'static str, client: &Arc, timeout: Option, server_instructions: Option<&str>, ) -> Result> { + let fetch_start = Instant::now(); let resp = client .list_tools_with_connector_ids(/*params*/ None, timeout) .await?; @@ -583,6 +596,19 @@ pub(crate) async fn list_tools_for_client_uncached( ) }) .collect(); + if is_codex_apps_mcp_server { + emit_duration( + MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, + fetch_start.elapsed(), + &[("trigger", codex_apps_refresh_trigger)], + ); + } else { + emit_duration( + MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, + fetch_start.elapsed(), + &[], + ); + } Ok(tools) } @@ -820,24 +846,19 @@ async fn start_server_task( .and_then(|exp| exp.get(MCP_SANDBOX_STATE_META_CAPABILITY)) .is_some(); let list_start = Instant::now(); - let fetch_start = Instant::now(); let fetch_ticket = codex_apps_tools_cache_context .as_ref() .map(|cache_context| cache_context.begin_fetch(CodexAppsToolsFetchSource::Startup)); let tools = list_tools_for_client_uncached( &server_name, is_codex_apps_mcp_server, + /*codex_apps_refresh_trigger*/ "initial", &client, startup_timeout, initialize_result.instructions.as_deref(), ) .await .map_err(StartupOutcomeError::from)?; - emit_duration( - MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, - fetch_start.elapsed(), - &[], - ); let server_info = mcp_server_info_from_implementation(initialize_result.server_info); let tools = match (codex_apps_tools_cache_context.as_ref(), fetch_ticket) { (Some(cache_context), Some(fetch_ticket)) => { diff --git a/codex-rs/config/src/mcp_edit.rs b/codex-rs/config/src/mcp_edit.rs index 16b3904917..ef0d8eae48 100644 --- a/codex-rs/config/src/mcp_edit.rs +++ b/codex-rs/config/src/mcp_edit.rs @@ -198,6 +198,7 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { entry["default_tools_approval_mode"] = value(match approval_mode { AppToolApproval::Auto => "auto", AppToolApproval::Prompt => "prompt", + AppToolApproval::Writes => "writes", AppToolApproval::Approve => "approve", }); } @@ -242,6 +243,7 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { tool_entry["approval_mode"] = value(match approval_mode { AppToolApproval::Auto => "auto", AppToolApproval::Prompt => "prompt", + AppToolApproval::Writes => "writes", AppToolApproval::Approve => "approve", }); } diff --git a/codex-rs/config/src/mcp_types.rs b/codex-rs/config/src/mcp_types.rs index 550de84a0c..7043e0cb69 100644 --- a/codex-rs/config/src/mcp_types.rs +++ b/codex-rs/config/src/mcp_types.rs @@ -22,6 +22,7 @@ pub enum AppToolApproval { #[default] Auto, Prompt, + Writes, Approve, } diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index c7cfff8a92..a5257ae44f 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -69,7 +69,13 @@ pub use codex_extension_api::empty_extension_registry; pub use codex_features::Feature; pub use codex_features::Features; pub use codex_home::CodexHomeUserInstructionsProvider; +pub use codex_login::AuthHeaders; pub use codex_login::AuthManager; +pub use codex_login::CodexAuth; +pub use codex_login::ExternalAuth; +pub use codex_login::ExternalAuthFuture; +pub use codex_login::ExternalAuthRefreshContext; +pub use codex_login::ExternalAuthRefreshReason; pub use codex_login::default_client::set_default_originator; pub use codex_model_provider_info::OPENAI_PROVIDER_ID; pub use codex_model_provider_info::built_in_model_providers; diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 4d215b61d4..6fac0fafe9 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1283,6 +1283,7 @@ impl PluginsManager { self.track_plugin_install_failed( &plugin_id, plugin_install_error_type(&err), + plugin_install_sub_error_type(&err), err.to_string(), ); Err(err) @@ -1345,6 +1346,7 @@ impl PluginsManager { self.track_plugin_install_failed( &resolved.plugin_id, plugin_install_error_type(&err), + plugin_install_sub_error_type(&err), err.to_string(), ); return Err(err); @@ -1356,6 +1358,7 @@ impl PluginsManager { self.track_plugin_install_failed( &plugin_id, plugin_install_error_type(&err), + plugin_install_sub_error_type(&err), err.to_string(), ); Err(err) @@ -1383,6 +1386,7 @@ impl PluginsManager { self.track_plugin_install_failed( &plugin_id, marketplace_error_type(err), + /*sub_error_type*/ None, err.to_string(), ); } else { @@ -1398,11 +1402,13 @@ impl PluginsManager { &self, plugin_id: &PluginId, error_type: &'static str, + sub_error_type: Option, error_message: String, ) { tracing::warn!( plugin_id = %plugin_id.as_key(), error_type = %error_type, + sub_error_type = sub_error_type.as_deref(), error = %error_message, "plugin install failed" ); @@ -2674,6 +2680,16 @@ fn plugin_install_error_type(err: &PluginInstallError) -> &'static str { } } +fn plugin_install_sub_error_type(err: &PluginInstallError) -> Option { + match err { + PluginInstallError::Store(err) => err.sub_error_type(), + PluginInstallError::Marketplace(_) + | PluginInstallError::Remote(_) + | PluginInstallError::Config(_) + | PluginInstallError::Join(_) => None, + } +} + fn marketplace_error_type(err: &MarketplaceError) -> &'static str { match err { MarketplaceError::Io { .. } => "marketplace_io", diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 469a9a7fc5..5e743416f2 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -781,6 +781,7 @@ fn remote_installed_plugin_in_marketplace( name: name.to_string(), enabled: true, install_policy: codex_app_server_protocol::PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: codex_app_server_protocol::PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: None, diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 4c9bd2e672..b9b94f4ded 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -6,6 +6,7 @@ use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInstallPolicySource; use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::SkillInterface; use codex_login::CodexAuth; @@ -152,6 +153,7 @@ pub struct RemoteInstalledPlugin { pub name: String, pub enabled: bool, pub install_policy: PluginInstallPolicy, + pub install_policy_source: Option, pub auth_policy: PluginAuthPolicy, pub availability: PluginAvailability, pub interface: Option, @@ -169,6 +171,7 @@ pub struct RemotePluginSummary { pub installed: bool, pub enabled: bool, pub install_policy: PluginInstallPolicy, + pub install_policy_source: Option, pub auth_policy: PluginAuthPolicy, pub availability: PluginAvailability, pub interface: Option, @@ -528,6 +531,26 @@ struct RemoteAppTemplateResponse { reason: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +enum RemotePluginInstallPolicySource { + #[serde(rename = "WORKSPACE_SETTING")] + WorkspaceSetting, + #[serde(rename = "IMPLICIT_CANONICAL_APP")] + ImplicitCanonicalApp, + #[serde(other)] + Unknown, +} + +impl RemotePluginInstallPolicySource { + fn into_protocol(self) -> Option { + match self { + Self::WorkspaceSetting => Some(PluginInstallPolicySource::WorkspaceSetting), + Self::ImplicitCanonicalApp => Some(PluginInstallPolicySource::ImplicitCanonicalApp), + Self::Unknown => None, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginDirectoryItem { id: String, @@ -544,6 +567,7 @@ struct RemotePluginDirectoryItem { #[serde(default)] share_principals: Option>, installation_policy: PluginInstallPolicy, + installation_policy_source: Option, authentication_policy: PluginAuthPolicy, #[serde(rename = "status", default)] availability: PluginAvailability, @@ -1050,6 +1074,7 @@ pub fn group_remote_installed_plugins_by_marketplaces( installed: true, enabled: plugin.enabled, install_policy: plugin.install_policy, + install_policy_source: plugin.install_policy_source, auth_policy: plugin.auth_policy, availability: plugin.availability, interface: plugin.interface.clone(), @@ -1483,6 +1508,9 @@ fn build_remote_plugin_summary( installed: installed_plugin.is_some(), enabled: installed_plugin.is_some_and(|plugin| plugin.enabled), install_policy: plugin.installation_policy, + install_policy_source: plugin + .installation_policy_source + .and_then(RemotePluginInstallPolicySource::into_protocol), auth_policy: plugin.authentication_policy, availability: plugin.availability, interface: remote_plugin_interface_to_info(plugin), @@ -1562,6 +1590,9 @@ fn remote_installed_plugin_to_cache_entry( name: plugin.name.clone(), enabled: installed_plugin.enabled, install_policy: plugin.installation_policy, + install_policy_source: plugin + .installation_policy_source + .and_then(RemotePluginInstallPolicySource::into_protocol), auth_policy: plugin.authentication_policy, availability: plugin.availability, interface: remote_plugin_interface_to_info(plugin), diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index d7744681b9..e9887370c5 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -648,6 +648,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { installed: false, enabled: false, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, interface: Some(expected_plugin_interface()), @@ -687,6 +688,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { installed: true, enabled: true, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, interface: Some(expected_plugin_interface()), diff --git a/codex-rs/core-plugins/src/remote_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs index 66592dc363..7a302f2f36 100644 --- a/codex-rs/core-plugins/src/remote_bundle.rs +++ b/codex-rs/core-plugins/src/remote_bundle.rs @@ -4,6 +4,7 @@ use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use crate::store::PluginInstallResult; use crate::store::PluginStore; use crate::store::PluginStoreError; +use crate::store::error_context_sub_error_type; use crate::store::validate_plugin_version_segment; use codex_login::default_client::build_reqwest_client; use codex_plugin::PluginId; @@ -132,6 +133,26 @@ impl RemotePluginBundleInstallError { fn io(context: &'static str, source: io::Error) -> Self { Self::Io { context, source } } + + pub fn sub_error_type(&self) -> Option { + match self { + Self::Io { context, .. } => Some(error_context_sub_error_type(context)), + Self::Store(err) => err.sub_error_type(), + Self::MissingReleaseVersion { .. } + | Self::InvalidReleaseVersion { .. } + | Self::MissingBundleDownloadUrl { .. } + | Self::InvalidBundleDownloadUrl { .. } + | Self::UnsupportedBundleDownloadUrlScheme { .. } + | Self::InvalidPluginId { .. } + | Self::DownloadRequest { .. } + | Self::DownloadStatus { .. } + | Self::DownloadBody { .. } + | Self::DownloadTooLarge { .. } + | Self::UnsupportedBundleDownloadFinalUrl { .. } + | Self::ExtractedBundleTooLarge { .. } + | Self::InvalidBundle(_) => None, + } + } } pub fn validate_remote_plugin_bundle( diff --git a/codex-rs/core-plugins/src/remote_tests.rs b/codex-rs/core-plugins/src/remote_tests.rs index f1d4478d7f..29fe8add19 100644 --- a/codex-rs/core-plugins/src/remote_tests.rs +++ b/codex-rs/core-plugins/src/remote_tests.rs @@ -33,6 +33,69 @@ fn build_remote_marketplace_preserves_directory_order_and_appends_installed_only ); } +#[test] +fn installation_policy_source_is_preserved_across_remote_summary_paths() { + let mut directory_plugin = directory_plugin("plugin-linear", "linear"); + directory_plugin.installation_policy_source = + Some(RemotePluginInstallPolicySource::ImplicitCanonicalApp); + let installed_plugin = RemotePluginInstalledItem { + plugin: directory_plugin.clone(), + enabled: true, + disabled_skill_names: Vec::new(), + }; + + let marketplace = build_remote_marketplace( + REMOTE_GLOBAL_MARKETPLACE_NAME, + REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, + vec![directory_plugin], + vec![installed_plugin.clone()], + /*include_installed_only*/ false, + ) + .expect("marketplace should be valid") + .expect("marketplace should not be empty"); + assert_eq!( + marketplace + .plugins + .into_iter() + .map(|plugin| plugin.install_policy_source) + .collect::>(), + vec![Some(PluginInstallPolicySource::ImplicitCanonicalApp)] + ); + + let mut installed_plugin = installed_plugin; + installed_plugin.plugin.installation_policy_source = + Some(RemotePluginInstallPolicySource::WorkspaceSetting); + let installed_plugin = remote_installed_plugin_to_cache_entry(&installed_plugin) + .expect("installed plugin should be valid"); + let marketplaces = group_remote_installed_plugins_by_marketplaces( + &[installed_plugin], + &[REMOTE_GLOBAL_MARKETPLACE_NAME], + ); + assert_eq!( + marketplaces + .into_iter() + .flat_map(|marketplace| marketplace.plugins) + .map(|plugin| plugin.install_policy_source) + .collect::>(), + vec![Some(PluginInstallPolicySource::WorkspaceSetting)] + ); +} + +#[test] +fn unknown_installation_policy_source_maps_to_none() { + let plugin = directory_plugin("plugin-linear", "linear"); + let mut plugin_json = serde_json::to_value(plugin).expect("plugin should serialize"); + plugin_json["installation_policy_source"] = + serde_json::Value::String("FUTURE_POLICY_SOURCE".to_string()); + let plugin: RemotePluginDirectoryItem = + serde_json::from_value(plugin_json).expect("unknown source should deserialize"); + + let summary = build_remote_plugin_summary(&plugin, /*installed_plugin*/ None) + .expect("summary should be valid"); + + assert_eq!(summary.install_policy_source, None); +} + fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem { RemotePluginDirectoryItem { id: id.to_string(), @@ -44,6 +107,7 @@ fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem { share_url: None, share_principals: None, installation_policy: PluginInstallPolicy::Available, + installation_policy_source: None, authentication_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, release: RemotePluginReleaseResponse { diff --git a/codex-rs/core-plugins/src/store.rs b/codex-rs/core-plugins/src/store.rs index bb4929dedb..819f9731be 100644 --- a/codex-rs/core-plugins/src/store.rs +++ b/codex-rs/core-plugins/src/store.rs @@ -365,6 +365,17 @@ impl PluginStoreError { fn io(context: &'static str, source: io::Error) -> Self { Self::Io { context, source } } + + pub(crate) fn sub_error_type(&self) -> Option { + match self { + Self::Io { context, .. } => Some(error_context_sub_error_type(context)), + Self::Invalid(_) => None, + } + } +} + +pub(crate) fn error_context_sub_error_type(context: &str) -> String { + context.to_ascii_lowercase().replace(' ', "_") } pub fn plugin_version_for_source(source_path: &Path) -> Result { diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index c9ace54204..e74eb01a0c 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -1,4 +1,5 @@ mod environment; +mod namespace; pub use environment::EnvironmentSkillLoadOutcome; pub use environment::EnvironmentSkillMetadata; @@ -27,9 +28,10 @@ use codex_utils_absolute_path::AbsolutePathBufGuard; use codex_utils_path_uri::PathUri; use codex_utils_plugins::DISCOVERABLE_PLUGIN_MANIFEST_PATHS; use codex_utils_plugins::PluginSkillRoot; -use codex_utils_plugins::plugin_namespace_for_skill_path; use dirs::home_dir; use futures::future::join_all; +use namespace::ResolvedSkillNamespace; +use namespace::SkillNamespaceResolver; use serde::Deserialize; use std::collections::HashSet; use std::collections::VecDeque; @@ -680,12 +682,27 @@ async fn load_skills_under_root( }; let SkillFileDiscovery { skill_files, + plugin_roots, + namespace_roots, warnings, - .. } = discover_skills_under_root(fs, &PathUri::from_abs_path(root), symlink_policy).await; for warning in warnings { error!("{warning}"); } + let root_uri = PathUri::from_abs_path(root); + let namespace_resolver = match plugin_namespace { + Some(namespace) => SkillNamespaceResolver::with_provided_namespace(namespace), + None => { + SkillNamespaceResolver::discover( + fs, + &root_uri, + &skill_files, + plugin_roots, + namespace_roots, + ) + .await + } + }; for path_uri in skill_files { let path = match path_uri.to_abs_path() { Ok(path) => path, @@ -699,7 +716,7 @@ async fn load_skills_under_root( &path, scope, plugin_id, - plugin_namespace, + namespace_resolver.for_skill(&root_uri, &path_uri), plugin_root.as_ref(), ) .await @@ -719,7 +736,7 @@ async fn parse_skill_file( path: &AbsolutePathBuf, scope: SkillScope, plugin_id: Option<&str>, - plugin_namespace: Option<&str>, + namespace: &ResolvedSkillNamespace, plugin_root: Option<&AbsolutePathBuf>, ) -> Result { let path_uri = PathUri::from_abs_path(path); @@ -732,7 +749,7 @@ async fn parse_skill_file( description, short_description, } = parse_skill_frontmatter_metadata_inner(&contents, || default_skill_name(path))?; - let name = namespaced_skill_name(fs, path, &base_name, plugin_namespace).await; + let name = namespace.qualify(&base_name); let LoadedSkillMetadata { interface, dependencies, @@ -818,21 +835,6 @@ fn default_skill_name(path: &AbsolutePathBuf) -> String { .unwrap_or_else(|| "skill".to_string()) } -async fn namespaced_skill_name( - fs: &dyn ExecutorFileSystem, - path: &AbsolutePathBuf, - base_name: &str, - plugin_namespace: Option<&str>, -) -> String { - if let Some(plugin_namespace) = plugin_namespace { - return format!("{plugin_namespace}:{base_name}"); - } - plugin_namespace_for_skill_path(fs, path) - .await - .map(|namespace| format!("{namespace}:{base_name}")) - .unwrap_or_else(|| base_name.to_string()) -} - async fn load_skill_metadata( fs: &dyn ExecutorFileSystem, skill_path: &AbsolutePathBuf, diff --git a/codex-rs/core-skills/src/loader/environment.rs b/codex-rs/core-skills/src/loader/environment.rs index 049b500585..8d400d7cdc 100644 --- a/codex-rs/core-skills/src/loader/environment.rs +++ b/codex-rs/core-skills/src/loader/environment.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::collections::HashSet; use std::io; @@ -8,10 +7,7 @@ use codex_exec_server::WalkOptions; use codex_protocol::protocol::Product; use codex_utils_path_uri::PathUri; use codex_utils_plugins::DISCOVERABLE_PLUGIN_MANIFEST_PATHS; -use codex_utils_plugins::plugin_namespace_for_root_uri; -use codex_utils_plugins::plugin_namespace_for_skill_uri; use futures::StreamExt; -use futures::future::join_all; use crate::model::SkillDependencies; use crate::model::SkillPolicy; @@ -24,6 +20,7 @@ use super::SKILLS_FILENAME; use super::SKILLS_METADATA_DIR; use super::SKILLS_METADATA_FILENAME; use super::SkillMetadataFile; +use super::namespace::SkillNamespaceResolver; use super::parse_skill_frontmatter_metadata_inner; use super::resolve_dependencies; use super::resolve_policy; @@ -143,6 +140,12 @@ pub struct EnvironmentSkillLoadOutcome { } /// Discovers skills without converting environment-owned paths to host paths. +#[tracing::instrument( + name = "skills.environment.load", + level = "info", + skip_all, + fields(skill_count = tracing::field::Empty) +)] pub async fn load_environment_skills_from_root( file_system: &dyn ExecutorFileSystem, root: &PathUri, @@ -157,6 +160,7 @@ pub async fn load_environment_skills_from_root( max_directories: MAX_SKILLS_DIRS_PER_ROOT, max_entries: MAX_SKILLS_ENTRIES_PER_ROOT, follow_directory_symlinks: true, + prune_hidden_directories: false, }, /*sandbox*/ None, ) @@ -235,50 +239,24 @@ pub async fn load_environment_skills_from_root( warnings: vec![format!("failed to walk skills root {root}: {error:#}")], }, }; + tracing::Span::current().record("skill_count", discovery.skills.len()); outcome.warnings.extend(discovery.warnings); if discovery.skills.is_empty() { return outcome; } - let mut skill_ancestors = HashSet::new(); - for skill in &discovery.skills { - let mut ancestor = skill.path.parent(); - while let Some(path) = ancestor { - skill_ancestors.insert(path.clone()); - ancestor = path.parent(); - } - } - - let namespace_roots = discovery.namespace_roots; - let plugin_namespaces = async { - let namespace_lookups = join_all(namespace_roots.iter().map(|namespace_root| async { - ( - namespace_root.clone(), - plugin_namespace_for_skill_uri(file_system, namespace_root).await, - ) - })); - let plugin_lookups = join_all( - discovery - .plugin_roots - .iter() - .filter(|plugin_root| skill_ancestors.contains(*plugin_root)) - .filter(|plugin_root| !namespace_roots.contains(*plugin_root)) - .map(|plugin_root| async { - ( - plugin_root.clone(), - plugin_namespace_for_root_uri(file_system, plugin_root).await, - ) - }), - ); - let (namespace_lookups, plugin_lookups) = tokio::join!(namespace_lookups, plugin_lookups); - namespace_lookups - .into_iter() - .chain(plugin_lookups) - .filter_map(|(plugin_root, namespace)| { - namespace.map(|namespace| (plugin_root, namespace)) - }) - .collect::>() - }; + let skill_paths = discovery + .skills + .iter() + .map(|skill| skill.path.clone()) + .collect::>(); + let namespace_resolver = SkillNamespaceResolver::discover( + file_system, + root, + &skill_paths, + discovery.plugin_roots, + discovery.namespace_roots, + ); // Remote executors can multiplex these independent per-skill reads, so polling a bounded // number together allows the I/O for each skill and its metadata to happen concurrently. @@ -294,23 +272,13 @@ pub async fn load_environment_skills_from_root( }) .buffered(MAX_CONCURRENT_SKILL_LOADS) .collect::>(); - let (plugin_namespaces, skill_results) = tokio::join!(plugin_namespaces, skill_results); + let (namespace_resolver, skill_results) = tokio::join!(namespace_resolver, skill_results); for (path, result) in skill_results { let result = result.and_then(|skill| { - let mut ancestor = skill.path_to_skills_md.parent(); - let plugin_namespace = loop { - let Some(current) = ancestor else { - break None; - }; - if let Some(namespace) = plugin_namespaces.get(¤t) { - break Some(namespace.as_str()); - } - ancestor = current.parent(); - }; - let name = plugin_namespace - .map(|namespace| format!("{namespace}:{}", skill.base_name)) - .unwrap_or(skill.base_name); + let name = namespace_resolver + .for_skill(root, &skill.path_to_skills_md) + .qualify(&skill.base_name); validate_len(&name, MAX_QUALIFIED_NAME_LEN, "qualified name") .map_err(|err| err.to_string())?; diff --git a/codex-rs/core-skills/src/loader/namespace.rs b/codex-rs/core-skills/src/loader/namespace.rs new file mode 100644 index 0000000000..34d8e9b7c4 --- /dev/null +++ b/codex-rs/core-skills/src/loader/namespace.rs @@ -0,0 +1,134 @@ +use codex_exec_server::ExecutorFileSystem; +use codex_utils_path_uri::PathUri; +use codex_utils_plugins::plugin_namespace_for_root_uri; +use codex_utils_plugins::plugin_namespace_for_skill_uri; +use futures::future::join_all; +use std::collections::HashSet; + +/// Resolves the namespace prefix applied to skill names during one skills scan. +/// +/// A plugin namespace is the plugin name from the nearest valid plugin manifest +/// above a skill path. For example, a skill named `search` beneath a plugin named +/// `sample` is exposed as `sample:search`. +/// +/// Resolving the namespace separately for every `SKILL.md` repeats the same +/// ancestor manifest probes for sibling skills. This resolver resolves relevant +/// roots once per scan, then selects the nearest matching root for each skill. +/// +/// Namespace precedence is: +/// +/// 1. an explicitly provided plugin namespace; +/// 2. the deepest matching canonical symlink root or nested plugin root; +/// 3. the namespace inherited from the scanned skills root. +pub(crate) struct SkillNamespaceResolver { + inherited_namespace: ResolvedSkillNamespace, + nested_namespaces: Vec<(PathUri, ResolvedSkillNamespace)>, +} + +impl SkillNamespaceResolver { + /// Builds a resolver whose explicit plugin-owned namespace overrides discovery. + pub(crate) fn with_provided_namespace(namespace: &str) -> Self { + Self { + inherited_namespace: ResolvedSkillNamespace::Plugin(namespace.to_string()), + nested_namespaces: Vec::new(), + } + } + + pub(crate) async fn discover( + fs: &dyn ExecutorFileSystem, + root: &PathUri, + skill_paths: &[PathUri], + plugin_roots: HashSet, + namespace_roots: HashSet, + ) -> Self { + // Only probe plugin roots above loaded skills; unused siblings cannot affect names. + let mut skill_ancestors = HashSet::new(); + for skill_path in skill_paths { + let mut ancestor = skill_path.parent(); + while let Some(path) = ancestor { + skill_ancestors.insert(path.clone()); + ancestor = path.parent(); + } + } + let plugin_roots = plugin_roots + .into_iter() + .filter(|plugin_root| skill_ancestors.contains(plugin_root)) + .collect::>(); + + // Ordinary descendants fall back to the nearest valid manifest at or above the scan root. + let inherited_namespace = plugin_namespace_for_skill_uri(fs, root) + .await + .map(ResolvedSkillNamespace::Plugin) + .unwrap_or(ResolvedSkillNamespace::Plain); + // The scan root is already the fallback above if nothing else matches, exclude from the search. + let namespace_roots = namespace_roots + .into_iter() + .filter(|namespace_root| namespace_root != root) + .collect::>(); + let namespace_root_set = namespace_roots.iter().cloned().collect::>(); + // Keep independent probes concurrent for remote executor latency. + let namespace_lookups = join_all(namespace_roots.into_iter().map(|namespace_root| async { + let namespace = plugin_namespace_for_skill_uri(fs, &namespace_root) + .await + .map(ResolvedSkillNamespace::Plugin) + .unwrap_or(ResolvedSkillNamespace::Plain); + (namespace_root, namespace) + })); + // Invalid nested manifests are omitted, so the deepest remaining match wins. + let plugin_lookups = join_all( + plugin_roots + .into_iter() + .filter(|plugin_root| { + plugin_root != root && !namespace_root_set.contains(plugin_root) + }) + .map(|plugin_root| async move { + plugin_namespace_for_root_uri(fs, &plugin_root) + .await + .map(|namespace| (plugin_root, ResolvedSkillNamespace::Plugin(namespace))) + }), + ); + let (namespace_lookups, plugin_lookups) = tokio::join!(namespace_lookups, plugin_lookups); + let nested_namespaces = namespace_lookups + .into_iter() + .chain(plugin_lookups.into_iter().flatten()) + .collect(); + + Self { + inherited_namespace, + nested_namespaces, + } + } + + pub(crate) fn for_skill(&self, root: &PathUri, path: &PathUri) -> &ResolvedSkillNamespace { + // Ancestor symlink targets cannot override skills still owned by the scan root. + let path_is_under_root = path.starts_with(root); + // The deepest matching path prefix is the nearest applicable namespace. + self.nested_namespaces + .iter() + .filter(|(namespace_root, _)| { + path.starts_with(namespace_root) + && (!path_is_under_root || !root.starts_with(namespace_root)) + }) + .max_by_key(|(namespace_root, _)| namespace_root.ancestors().count()) + .map(|(_, namespace)| namespace) + .unwrap_or(&self.inherited_namespace) + } +} + +/// The completed namespace resolution for a skill root. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ResolvedSkillNamespace { + /// No plugin namespace applies to matching skills. + Plain, + /// Qualify matching skill names with this plugin namespace. + Plugin(String), +} + +impl ResolvedSkillNamespace { + pub(crate) fn qualify(&self, base_name: &str) -> String { + match self { + Self::Plain => base_name.to_string(), + Self::Plugin(namespace) => format!("{namespace}:{base_name}"), + } + } +} diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index 8cd371c9a7..1bdfe39520 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -388,6 +388,41 @@ fn write_skill_interface_at(skill_dir: &Path, contents: &str) -> PathBuf { write_skill_metadata_at(skill_dir, contents) } +fn write_plugin_manifest(plugin_root: &Path, contents: &str) { + let manifest_path = plugin_root.join(".codex-plugin/plugin.json"); + fs::create_dir_all(manifest_path.parent().expect("manifest parent")).unwrap(); + fs::write(manifest_path, contents).unwrap(); +} + +async fn load_user_skills_root(root: &Path) -> SkillLoadOutcome { + load_skills_from_roots( + [SkillRoot { + path: root.abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + plugin_namespace: None, + plugin_root: None, + }], + /*plugin_skill_snapshots*/ None, + ) + .await +} + +fn expected_user_skill(path: &Path, name: &str, description: &str) -> SkillMetadata { + SkillMetadata { + name: name.to_string(), + description: description.to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(path), + scope: SkillScope::User, + plugin_id: None, + } +} + #[tokio::test] async fn loads_skill_dependencies_metadata_from_yaml() { let codex_home = tempfile::tempdir().expect("tempdir"); @@ -1326,6 +1361,246 @@ async fn namespaces_plugin_skills_using_provided_namespace() { ); } +#[tokio::test] +async fn namespaces_nested_plugin_skills_without_namespacing_plain_siblings() { + let root = tempfile::tempdir().expect("tempdir"); + let skills_root = root.path().join("skills"); + let plain_skill_path = + write_skill_at(&skills_root, "plain", "plain-skill", "plain description"); + let plugin_root = skills_root.join("nested-plugin"); + write_plugin_manifest(&plugin_root, r#"{"name":"nested"}"#); + let plugin_skill_path = write_skill_at( + &plugin_root.join("skills"), + "search", + "plugin-skill", + "plugin description", + ); + + let outcome = load_user_skills_root(&skills_root).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![ + expected_user_skill( + &plugin_skill_path, + "nested:plugin-skill", + "plugin description" + ), + expected_user_skill(&plain_skill_path, "plain-skill", "plain description"), + ] + ); +} + +#[tokio::test] +async fn inherits_plugin_namespace_from_above_scanned_skills_root() { + let root = tempfile::tempdir().expect("tempdir"); + let plugin_root = root.path().join("plugin"); + write_plugin_manifest(&plugin_root, r#"{"name":"outer"}"#); + let skills_root = plugin_root.join("skills"); + let skill_path = write_skill_at(&skills_root, "search", "search-skill", "search description"); + + let outcome = load_user_skills_root(&skills_root).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![expected_user_skill( + &skill_path, + "outer:search-skill", + "search description", + )] + ); +} + +#[tokio::test] +async fn nearest_valid_nested_plugin_namespace_overrides_outer_namespace() { + let root = tempfile::tempdir().expect("tempdir"); + let outer_plugin_root = root.path().join("outer-plugin"); + write_plugin_manifest(&outer_plugin_root, r#"{"name":"outer"}"#); + let skills_root = outer_plugin_root.join("skills"); + let nested_plugin_root = skills_root.join("nested-plugin"); + write_plugin_manifest(&nested_plugin_root, r#"{"name":"nested"}"#); + let skill_path = write_skill_at( + &nested_plugin_root.join("skills"), + "search", + "search-skill", + "search description", + ); + + let outcome = load_user_skills_root(&skills_root).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![expected_user_skill( + &skill_path, + "nested:search-skill", + "search description", + )] + ); +} + +#[tokio::test] +async fn invalid_nested_plugin_manifest_falls_back_to_outer_namespace() { + let root = tempfile::tempdir().expect("tempdir"); + let outer_plugin_root = root.path().join("outer-plugin"); + write_plugin_manifest(&outer_plugin_root, r#"{"name":"outer"}"#); + let skills_root = outer_plugin_root.join("skills"); + let nested_plugin_root = skills_root.join("nested-plugin"); + write_plugin_manifest(&nested_plugin_root, "not json"); + let skill_path = write_skill_at( + &nested_plugin_root.join("skills"), + "search", + "search-skill", + "search description", + ); + + let outcome = load_user_skills_root(&skills_root).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![expected_user_skill( + &skill_path, + "outer:search-skill", + "search description", + )] + ); +} + +// Directory symlinks on Windows can require Developer Mode or administrator privileges. +#[cfg(unix)] +#[tokio::test] +async fn namespaces_skills_in_symlinked_plugin_skills_dir() { + // skills/ + // └── linked-plugin -> shared-plugin/skills/ + // shared-plugin/ + // ├── .codex-plugin/plugin.json + // └── skills/search/SKILL.md + let root = tempfile::tempdir().expect("tempdir"); + let shared_plugin_root = tempfile::tempdir().expect("tempdir"); + write_plugin_manifest(shared_plugin_root.path(), r#"{"name":"linked"}"#); + let skill_path = write_skill_at( + &shared_plugin_root.path().join("skills"), + "search", + "search-skill", + "search description", + ); + let skills_root = root.path().join("skills"); + fs::create_dir_all(&skills_root).unwrap(); + symlink_dir( + &shared_plugin_root.path().join("skills"), + &skills_root.join("linked-plugin"), + ); + + let outcome = load_user_skills_root(&skills_root).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![expected_user_skill( + &skill_path, + "linked:search-skill", + "search description", + )] + ); +} + +// Directory symlinks on Windows can require Developer Mode or administrator privileges. +#[cfg(unix)] +#[tokio::test] +async fn does_not_inherit_namespace_for_skills_in_symlinked_plain_dir() { + // outer-plugin/ + // ├── .codex-plugin/plugin.json + // └── skills/linked-plain -> plain-root/ + // plain-root/ + // └── search/SKILL.md + let root = tempfile::tempdir().expect("tempdir"); + let plugin_root = root.path().join("outer-plugin"); + write_plugin_manifest(&plugin_root, r#"{"name":"outer"}"#); + let skills_root = plugin_root.join("skills"); + let plain_root = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill_at( + plain_root.path(), + "search", + "plain-skill", + "plain description", + ); + fs::create_dir_all(&skills_root).unwrap(); + symlink_dir(plain_root.path(), &skills_root.join("linked-plain")); + + let outcome = load_user_skills_root(&skills_root).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![expected_user_skill( + &skill_path, + "plain-skill", + "plain description", + )] + ); +} + +// Directory symlinks on Windows can require Developer Mode or administrator privileges. +#[cfg(unix)] +#[tokio::test] +async fn keeps_inherited_namespace_when_symlink_target_is_scan_root_ancestor() { + // temp-root/ + // └── a/b/c/d/e/f/outer-plugin/ + // ├── .codex-plugin/plugin.json + // └── skills/ + // ├── root/SKILL.md + // └── link -> temp-root/ + let root = tempfile::tempdir().expect("tempdir"); + let plugin_root = root.path().join("a/b/c/d/e/f/outer-plugin"); + write_plugin_manifest(&plugin_root, r#"{"name":"outer"}"#); + let skills_root = plugin_root.join("skills"); + let skill_path = write_skill_at(&skills_root, "root", "root-skill", "root description"); + symlink_dir(root.path(), &skills_root.join("link")); + + let outcome = load_user_skills_root(&skills_root).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![expected_user_skill( + &skill_path, + "outer:root-skill", + "root description", + )] + ); +} + #[tokio::test] async fn plugin_skill_name_length_limit_allows_max_qualified_name() { let root = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 4435a2eb63..5dc2586b6a 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -49,6 +49,7 @@ codex-shell-command = { workspace = true } codex-execpolicy = { workspace = true } codex-git-utils = { workspace = true } codex-hooks = { workspace = true } +codex-http-client = { workspace = true } codex-install-context = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } @@ -133,6 +134,7 @@ openssl-sys = { workspace = true, features = ["vendored"] } codex-shell-escalation = { workspace = true } [dev-dependencies] +codex-extension-items = { workspace = true } assert_cmd = { workspace = true } assert_matches = { workspace = true } codex-image-generation-extension = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 876180dd7e..fb4d8740ba 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -150,6 +150,7 @@ "enum": [ "auto", "prompt", + "writes", "approve" ], "type": "string" @@ -469,6 +470,9 @@ "computer_use": { "type": "boolean" }, + "concurrent_reasoning_summaries": { + "type": "boolean" + }, "connectors": { "type": "boolean" }, @@ -4838,6 +4842,9 @@ "computer_use": { "type": "boolean" }, + "concurrent_reasoning_summaries": { + "type": "boolean" + }, "connectors": { "type": "boolean" }, diff --git a/codex-rs/core/src/apps/render.rs b/codex-rs/core/src/apps/render.rs index 2331e13f77..b3d47913f6 100644 --- a/codex-rs/core/src/apps/render.rs +++ b/codex-rs/core/src/apps/render.rs @@ -5,7 +5,10 @@ use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG; pub(crate) fn render_apps_section(connectors: &[AppInfo]) -> Option { - AppsInstructions::from_connectors(connectors).map(|instructions| instructions.render()) + connectors + .iter() + .any(|connector| connector.is_accessible && connector.is_enabled) + .then(|| AppsInstructions.render()) } #[cfg(test)] diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 43a758b0e8..8c98a3372e 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -56,16 +56,20 @@ use codex_api::ResponsesWebsocketConnection as ApiWebSocketConnection; use codex_api::ResponsesWsRequest; use codex_api::SharedAuthProvider; use codex_api::SseTelemetry; +use codex_api::StreamOptions; use codex_api::TransportError; use codex_api::WebsocketTelemetry; use codex_api::auth_header_telemetry; use codex_api::build_session_headers; use codex_api::create_text_param_for_request; use codex_api::response_create_client_metadata; +use codex_http_client::ClientRouteClass; +use codex_http_client::HttpClientFactory; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::RefreshTokenError; use codex_login::UnauthorizedRecovery; +use codex_login::default_client::build_default_reqwest_client_for_route; use codex_login::default_client::build_reqwest_client; use codex_otel::SessionTelemetry; use codex_otel::current_span_w3c_trace_context; @@ -203,6 +207,7 @@ struct ModelClientState { include_timing_metrics: bool, beta_features_header: Option, item_ids_enabled: bool, + concurrent_reasoning_summaries_enabled: bool, include_attestation: bool, attestation_provider: Option>, disable_websockets: AtomicBool, @@ -248,6 +253,7 @@ pub struct ModelClient { state: Arc, agent_identity_policy: AgentIdentityAuthPolicy, prompt_cache_key_override: Option, + http_client_factory: HttpClientFactory, } /// A turn-scoped streaming session created from a [`ModelClient`]. @@ -311,6 +317,7 @@ fn responses_request_properties_match( reasoning: previous_reasoning, store: previous_store, stream: previous_stream, + stream_options: _, include: previous_include, service_tier: previous_service_tier, prompt_cache_key: previous_prompt_cache_key, @@ -327,6 +334,7 @@ fn responses_request_properties_match( reasoning: current_reasoning, store: current_store, stream: current_stream, + stream_options: _, include: current_include, service_tier: current_service_tier, prompt_cache_key: current_prompt_cache_key, @@ -342,6 +350,8 @@ fn responses_request_properties_match( && previous_reasoning == current_reasoning && previous_store == current_store && previous_stream == current_stream + // Stream options control delivery for this response, not the context + // referenced by `previous_response_id`. && previous_include == current_include && previous_service_tier == current_service_tier && previous_prompt_cache_key == current_prompt_cache_key @@ -396,7 +406,9 @@ impl ModelClient { /// Creates a new session-scoped `ModelClient`. /// /// All arguments are expected to be stable for the lifetime of a Codex session. Per-turn values - /// are passed to [`ModelClientSession::stream`] (and other turn-scoped methods) explicitly. + /// are passed to [`ModelClientSession::stream`] (and other turn-scoped methods) explicitly. The + /// HTTP client factory must come from the effective session configuration so every transport + /// observes the resolved outbound proxy policy. pub fn new( auth_manager: Option>, agent_identity_policy: AgentIdentityAuthPolicy, @@ -409,7 +421,9 @@ impl ModelClient { include_timing_metrics: bool, beta_features_header: Option, item_ids_enabled: bool, + concurrent_reasoning_summaries_enabled: bool, attestation_provider: Option>, + http_client_factory: HttpClientFactory, ) -> Self { let model_provider = create_model_provider(provider_info, auth_manager); let codex_api_key_env_enabled = model_provider @@ -431,6 +445,7 @@ impl ModelClient { include_timing_metrics, beta_features_header, item_ids_enabled, + concurrent_reasoning_summaries_enabled, include_attestation, attestation_provider, disable_websockets: AtomicBool::new(false), @@ -439,6 +454,7 @@ impl ModelClient { }), agent_identity_policy, prompt_cache_key_override: None, + http_client_factory, } } @@ -532,7 +548,8 @@ impl ModelClient { return Ok(Vec::new()); } let client_setup = self.current_client_setup().await?; - let transport = ReqwestTransport::new(build_reqwest_client()); + let transport = + self.build_responses_transport(&client_setup.api_provider, RESPONSES_COMPACT_ENDPOINT)?; let request_telemetry = Self::build_request_telemetry( session_telemetry, AuthRequestTelemetryContext::new( @@ -820,7 +837,8 @@ impl ModelClient { responses_metadata: &CodexResponsesMetadata, ) -> Result { let mut input = prompt.get_formatted_input_for_request(model_info.use_responses_lite); - if !self.state.provider.info().is_openai() { + let is_openai = self.state.provider.info().is_openai(); + if !is_openai { input .iter_mut() .for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough); @@ -848,6 +866,10 @@ impl ModelClient { } else { (prompt.base_instructions.text.clone(), Some(tools)) }; + let stream_options = (self.state.concurrent_reasoning_summaries_enabled && is_openai) + .then_some(StreamOptions { + reasoning_summary_delivery: codex_api::ReasoningSummaryDelivery::SequentialCutoff, + }); let reasoning = Self::build_reasoning(model_info, effort, summary); let include = if reasoning.is_some() { vec!["reasoning.encrypted_content".to_string()] @@ -882,6 +904,7 @@ impl ModelClient { reasoning, store: provider.is_azure_responses_endpoint(), stream: true, + stream_options, include, service_tier, prompt_cache_key, @@ -938,6 +961,21 @@ impl ModelClient { }) } + fn build_responses_transport( + &self, + api_provider: &ApiProvider, + endpoint: &str, + ) -> Result { + let request_url = api_provider.url_for_path(endpoint); + let client = build_default_reqwest_client_for_route( + &self.http_client_factory, + &request_url, + ClientRouteClass::Api, + ) + .map_err(std::io::Error::from)?; + Ok(ReqwestTransport::new(client)) + } + pub(crate) async fn prewarm_auth(&self) -> Result<()> { self.current_client_setup().await.map(|_| ()) } @@ -1372,7 +1410,9 @@ impl ModelClientSession { let mut pending_retry = PendingUnauthorizedRetry::default(); loop { let client_setup = self.client.current_client_setup().await?; - let transport = ReqwestTransport::new(build_reqwest_client()); + let transport = self + .client + .build_responses_transport(&client_setup.api_provider, RESPONSES_ENDPOINT)?; let request_auth_context = AuthRequestTelemetryContext::new( client_setup.auth.as_ref().map(CodexAuth::auth_mode), client_setup.api_auth.as_ref(), @@ -2101,6 +2141,7 @@ impl AuthRequestTelemetryContext { AuthMode::ApiKey | AuthMode::BedrockApiKey => "ApiKey", AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens + | AuthMode::Headers | AuthMode::AgentIdentity | AuthMode::PersonalAccessToken => "Chatgpt", }), diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index 38c4aaecb5..b8d0a364a3 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -116,6 +116,7 @@ fn serializes_text_verbosity_when_set() { reasoning: None, store: false, stream: true, + stream_options: None, include: vec![], prompt_cache_key: None, service_tier: None, @@ -163,6 +164,7 @@ fn serializes_text_schema_with_strict_format() { reasoning: None, store: false, stream: true, + stream_options: None, include: vec![], prompt_cache_key: None, service_tier: None, @@ -224,6 +226,7 @@ fn omits_text_when_not_set() { reasoning: None, store: false, stream: true, + stream_options: None, include: vec![], prompt_cache_key: None, service_tier: None, @@ -247,6 +250,7 @@ fn serializes_flex_service_tier_when_set() { reasoning: None, store: false, stream: true, + stream_options: None, include: vec![], prompt_cache_key: None, service_tier: Some(ServiceTier::Flex.to_string()), diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 82c464625d..ddf9c4ebec 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -19,6 +19,8 @@ use codex_api::AgentIdentityTelemetry; use codex_api::ApiError; use codex_api::ResponseEvent; use codex_api::TransportError; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; use codex_login::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; use codex_login::AuthManager; @@ -103,7 +105,9 @@ fn test_model_client_with_thread_id( /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), ) } @@ -147,7 +151,9 @@ async fn compact_uses_bearer_after_agent_identity_session_fallback() -> anyhow:: /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), ); let prompt = Prompt { input: vec![ResponseItem::Message { @@ -821,9 +827,11 @@ fn model_client_with_counting_attestation( /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, Some(Arc::new(CountingAttestationProvider { calls: attestation_calls.clone(), })), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), ); (model_client, attestation_calls) } diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index a671f0dd74..4cba4bddc5 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -299,11 +299,11 @@ async fn forward_events( match event { Event { id: _, - msg: EventMsg::TokenCount(_), - } => {} - Event { - id: _, - msg: EventMsg::SessionConfigured(_), + msg: + EventMsg::TokenCount(_) + | EventMsg::SessionConfigured(_) + | EventMsg::McpStartupUpdate(_) + | EventMsg::McpStartupComplete(_), } => {} Event { id, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 9513dcc56c..5cb2251a4a 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -14,6 +14,9 @@ use codex_protocol::protocol::GuardianAssessmentAction; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::GuardianCommandSource; use codex_protocol::protocol::McpInvocation; +use codex_protocol::protocol::McpStartupCompleteEvent; +use codex_protocol::protocol::McpStartupStatus; +use codex_protocol::protocol::McpStartupUpdateEvent; use codex_protocol::protocol::RawResponseItemEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::TurnAbortReason; @@ -34,8 +37,8 @@ use tokio::sync::watch; use tokio::time::timeout; #[tokio::test] -async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { - let (tx_events, rx_events) = bounded(1); +async fn forward_events_filters_private_events_before_blocked_send_is_cancelled() { + let (tx_events, rx_events) = bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY); let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit); let (session, ctx, _rx_evt) = crate::session::tests::make_session_and_context_with_rx().await; @@ -71,33 +74,53 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { cancel.clone(), )); - tx_events - .send(Event { - id: "evt".to_string(), - msg: EventMsg::RawResponseItem(RawResponseItemEvent { - item: ResponseItem::CustomToolCall { - id: None, - status: None, - call_id: "call-1".to_string(), - name: "tool".to_string(), - namespace: None, - input: "{}".to_string(), - internal_chat_message_metadata_passthrough: None, - }, - }), - }) - .await - .unwrap(); + for msg in [ + EventMsg::McpStartupUpdate(McpStartupUpdateEvent { + server: "pending".to_string(), + status: McpStartupStatus::Starting, + }), + EventMsg::McpStartupComplete(McpStartupCompleteEvent::default()), + ] { + tx_events + .send(Event { + id: "delegate-startup".to_string(), + msg, + }) + .await + .unwrap(); + } + let visible_msg = EventMsg::RawResponseItem(RawResponseItemEvent { + item: ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "call-1".to_string(), + name: "tool".to_string(), + namespace: None, + input: "{}".to_string(), + internal_chat_message_metadata_passthrough: None, + }, + }); + for id in ["visible-1", "visible-2", "blocked"] { + tx_events + .send(Event { + id: id.to_string(), + msg: visible_msg.clone(), + }) + .await + .unwrap(); + } drop(tx_events); + let received = rx_out.recv().await.expect("prefilled event missing"); + assert_eq!(received.id, "full"); + let received = rx_out.recv().await.expect("visible event missing"); + assert_eq!(received.id, "visible-1"); cancel.cancel(); timeout(std::time::Duration::from_millis(1000), forward) .await .expect("forward_events hung") .expect("forward_events join error"); - let received = rx_out.recv().await.expect("prefilled event missing"); - assert_eq!("full", received.id); let mut ops = Vec::new(); while let Ok(sub) = rx_sub.try_recv() { ops.push(sub.op); diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 4a562092f1..c80268e2f9 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,5 +1,6 @@ use crate::agent::AgentStatus; use crate::config::ConstraintResult; +use crate::elicitation::ElicitationRegistration; use crate::session::Codex; use crate::session::SessionSettingsUpdate; use crate::session::SteerInputError; @@ -162,7 +163,13 @@ pub struct CodexThread { pub(crate) session_source: SessionSource, session_configured: SessionConfiguredEvent, rollout_path: Option, - out_of_band_elicitation_count: Mutex, + out_of_band_elicitations: Mutex, +} + +#[derive(Default)] +struct OutOfBandElicitations { + count: i64, + registration: Option, } #[derive(Debug, Eq, PartialEq)] @@ -187,7 +194,7 @@ impl CodexThread { session_source, session_configured, rollout_path, - out_of_band_elicitation_count: Mutex::new(0), + out_of_band_elicitations: Mutex::new(OutOfBandElicitations::default()), } } @@ -655,38 +662,30 @@ impl CodexThread { self.codex.enabled(feature) } - pub async fn increment_out_of_band_elicitation_count(&self) -> CodexResult { - let mut guard = self.out_of_band_elicitation_count.lock().await; - let was_zero = *guard == 0; - *guard = guard.checked_add(1).ok_or_else(|| { + pub async fn increment_out_of_band_elicitation_count(&self) -> CodexResult { + let mut elicitations = self.out_of_band_elicitations.lock().await; + let incremented = elicitations.count.checked_add(1).ok_or_else(|| { CodexErr::Fatal("out-of-band elicitation count overflowed".to_string()) })?; - - if was_zero { - self.codex - .session - .set_out_of_band_elicitation_pause_state(/*paused*/ true); + if elicitations.count == 0 { + elicitations.registration = Some(self.codex.session.services.elicitations.register()); } - - Ok(*guard) + elicitations.count = incremented; + Ok(incremented) } - pub async fn decrement_out_of_band_elicitation_count(&self) -> CodexResult { - let mut guard = self.out_of_band_elicitation_count.lock().await; - if *guard == 0 { + pub async fn decrement_out_of_band_elicitation_count(&self) -> CodexResult { + let mut elicitations = self.out_of_band_elicitations.lock().await; + if elicitations.count == 0 { return Err(CodexErr::InvalidRequest( "out-of-band elicitation count is already zero".to_string(), )); } - *guard -= 1; - let now_zero = *guard == 0; - if now_zero { - self.codex - .session - .set_out_of_band_elicitation_pause_state(/*paused*/ false); + elicitations.count -= 1; + if elicitations.count == 0 { + elicitations.registration = None; } - - Ok(*guard) + Ok(elicitations.count) } } diff --git a/codex-rs/core/src/compact_model_fallback.rs b/codex-rs/core/src/compact_model_fallback.rs new file mode 100644 index 0000000000..10d1ce743a --- /dev/null +++ b/codex-rs/core/src/compact_model_fallback.rs @@ -0,0 +1,49 @@ +use codex_analytics::CompactionImplementation; +use codex_analytics::CompactionReason; +use codex_otel::SessionTelemetry; +use codex_protocol::error::CodexErr; +use tracing::warn; + +pub(crate) fn record_model_fallback( + session_telemetry: &SessionTelemetry, + previous_model: &str, + current_model: &str, + reason: CompactionReason, + implementation: CompactionImplementation, + fallback_error: Option<&CodexErr>, +) { + let reason_tag = match reason { + CompactionReason::UserRequested => "user_requested", + CompactionReason::ContextLimit => "context_limit", + CompactionReason::ModelDownshift => "model_downshift", + CompactionReason::CompHashChanged => "comp_hash_changed", + }; + let implementation_tag = match implementation { + CompactionImplementation::Responses => "responses", + CompactionImplementation::ResponsesCompactionV2 => "responses_compaction_v2", + CompactionImplementation::ResponsesCompact => "responses_compact", + }; + let outcome = if fallback_error.is_none() { + "succeeded" + } else { + "failed" + }; + session_telemetry.counter( + "codex.compaction.model_fallback", + /*inc*/ 1, + &[ + ("reason", reason_tag), + ("implementation", implementation_tag), + ("outcome", outcome), + ], + ); + warn!( + previous_model, + current_model, + ?reason, + ?implementation, + outcome, + ?fallback_error, + "previous-model compaction failed; retried with current model" + ); +} diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 6b251562f7..4b1db93555 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -1,31 +1,27 @@ use std::sync::Arc; use std::sync::OnceLock; -use crate::Prompt; -use crate::client::CompactConversationRequestSettings; use crate::compact::CompactionAnalyticsAttempt; use crate::compact::CompactionAnalyticsDetails; use crate::compact::InitialContextInjection; use crate::compact::build_compaction_initial_context; use crate::compact::compaction_status_from_result; use crate::compact::insert_initial_context_before_last_real_user_or_summary; +use crate::compact_model_fallback::record_model_fallback; use crate::context::world_state::WorldState; use crate::context_manager::ContextManager; use crate::hook_runtime::PostCompactHookOutcome; use crate::hook_runtime::PreCompactHookOutcome; use crate::hook_runtime::run_post_compact_hooks; use crate::hook_runtime::run_pre_compact_hooks; -use crate::responses_metadata::CodexResponsesRequestKind; use crate::responses_metadata::CompactionTurnMetadata; use crate::session::session::Session; use crate::session::step_context::StepContext; -use crate::session::turn::built_tools; use crate::session::turn_context::TurnContext; use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::CompactionTrigger; -use codex_protocol::auth::AuthMode; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; @@ -38,8 +34,11 @@ use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TurnStartedEvent; use codex_rollout_trace::CompactionCheckpointTracePayload; -use tokio_util::sync::CancellationToken; -use tracing::info; + +#[path = "compact_remote_request.rs"] +mod request; +use request::RemoteCompactAttempt; +use request::run_remote_compact_attempt; const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: &str = "Output exceeded the available model context and was truncated"; @@ -47,19 +46,25 @@ const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: &str = pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, step_context: Arc, + fallback_step_context: Option>, turn_state: Arc>, initial_context_injection: InitialContextInjection, reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { + let compaction_metadata = CompactionTurnMetadata::new( + CompactionTrigger::Auto, + reason, + CompactionImplementation::ResponsesCompact, + phase, + ); run_remote_compact_task_inner( &sess, &step_context, + fallback_step_context.as_ref(), Some(turn_state), initial_context_injection, - CompactionTrigger::Auto, - reason, - phase, + compaction_metadata, ) .await?; Ok(()) @@ -80,14 +85,19 @@ pub(crate) async fn run_remote_compact_task( }); sess.send_event(&turn_context, start_event).await; + let compaction_metadata = CompactionTurnMetadata::new( + CompactionTrigger::Manual, + CompactionReason::UserRequested, + CompactionImplementation::ResponsesCompact, + CompactionPhase::StandaloneTurn, + ); run_remote_compact_task_inner( &sess, &step_context, + /*fallback_step_context*/ None, /*turn_state*/ None, InitialContextInjection::DoNotInject, - CompactionTrigger::Manual, - CompactionReason::UserRequested, - CompactionPhase::StandaloneTurn, + compaction_metadata, ) .await?; Ok(()) @@ -96,19 +106,16 @@ pub(crate) async fn run_remote_compact_task( async fn run_remote_compact_task_inner( sess: &Arc, step_context: &Arc, + fallback_step_context: Option<&Arc>, turn_state: Option>>, initial_context_injection: InitialContextInjection, - trigger: CompactionTrigger, - reason: CompactionReason, - phase: CompactionPhase, + compaction_metadata: CompactionTurnMetadata, ) -> CodexResult<()> { let turn_context = &step_context.turn; - let compaction_metadata = CompactionTurnMetadata::new( - trigger, - reason, - CompactionImplementation::ResponsesCompact, - phase, - ); + let trigger = compaction_metadata.trigger(); + let reason = compaction_metadata.reason(); + let implementation = compaction_metadata.implementation(); + let phase = compaction_metadata.phase(); let mut analytics_details = CompactionAnalyticsDetails { active_context_tokens_before: Some(sess.get_total_token_usage().await), ..Default::default() @@ -118,7 +125,7 @@ async fn run_remote_compact_task_inner( turn_context.as_ref(), trigger, reason, - CompactionImplementation::ResponsesCompact, + implementation, phase, ) .await; @@ -141,6 +148,7 @@ async fn run_remote_compact_task_inner( let result = run_remote_compact_task_inner_impl( sess, step_context, + fallback_step_context, turn_state, initial_context_injection, compaction_metadata, @@ -175,6 +183,7 @@ async fn run_remote_compact_task_inner( async fn run_remote_compact_task_inner_impl( sess: &Arc, step_context: &Arc, + fallback_step_context: Option<&Arc>, turn_state: Option>>, initial_context_injection: InitialContextInjection, compaction_metadata: CompactionTurnMetadata, @@ -182,93 +191,75 @@ async fn run_remote_compact_task_inner_impl( ) -> CodexResult<()> { let turn_context = &step_context.turn; let context_compaction_item = ContextCompactionItem::new(); + let compaction_id = context_compaction_item.id.clone(); // Use the UI compaction item ID as the trace compaction ID so protocol lifecycle events, // endpoint attempts, and the installed history checkpoint all have one join key. let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( turn_context.sub_id.as_str(), - context_compaction_item.id.as_str(), + compaction_id.as_str(), turn_context.model_info.slug.as_str(), turn_context.provider.info().name.as_str(), ); let compaction_item = TurnItem::ContextCompaction(context_compaction_item); sess.emit_turn_item_started(turn_context, &compaction_item) .await; - let mut history = sess.clone_history().await; - let base_instructions = sess.get_base_instructions().await; - let (rewritten_outputs, estimated_deleted_tokens) = - trim_function_call_history_to_fit_context_window( - &mut history, - turn_context.as_ref(), - &base_instructions, - ); - if rewritten_outputs > 0 { - info!( - turn_id = %turn_context.sub_id, - rewritten_outputs, - "rewrote history outputs before remote compaction" - ); - } - if estimated_deleted_tokens > 0 { - let max_local_deleted_tokens = sess - .estimated_tokens_after_last_model_generated_item() - .await; - analytics_details.active_context_tokens_before = analytics_details - .active_context_tokens_before - .map(|active_context_tokens_before| { - active_context_tokens_before - .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)) - }); - } - // This is the history selected for remote compaction, after any output rewriting required to - // fit the compact endpoint. The checkpoint below records it separately from the next sampling - // request, whose prompt will repeat current developer/context prefix items. - let trace_input_history = history.raw_items().to_vec(); - let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities); - let tool_router = built_tools( - sess.as_ref(), - step_context.as_ref(), - &CancellationToken::new(), + let attempt = run_remote_compact_attempt( + sess, + step_context, + turn_state.clone(), + &compaction_trace, + compaction_metadata, + analytics_details, ) - .await?; - let prompt = Prompt { - input: prompt_input, - tools: tool_router.model_visible_specs(), - parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, - base_instructions, - output_schema: None, - output_schema_strict: true, + .await; + let (attempt, compaction_turn_context) = match attempt { + Ok(attempt) => (attempt, turn_context), + Err(error) => { + let Some(fallback_step_context) = fallback_step_context else { + return Err(error); + }; + if !matches!(&error, CodexErr::InvalidRequest(_)) { + return Err(error); + } + let fallback_turn_context = &fallback_step_context.turn; + let fallback_compaction_trace = + sess.services.rollout_thread_trace.compaction_trace_context( + fallback_turn_context.sub_id.as_str(), + compaction_id.as_str(), + fallback_turn_context.model_info.slug.as_str(), + fallback_turn_context.provider.info().name.as_str(), + ); + let fallback_result = run_remote_compact_attempt( + sess, + fallback_step_context, + turn_state, + &fallback_compaction_trace, + compaction_metadata, + analytics_details, + ) + .await; + record_model_fallback( + &sess.services.session_telemetry, + turn_context.model_info.slug.as_str(), + fallback_turn_context.model_info.slug.as_str(), + compaction_metadata.reason(), + compaction_metadata.implementation(), + fallback_result.as_ref().err(), + ); + match fallback_result { + Ok(attempt) => (attempt, fallback_turn_context), + Err(_) => return Err(error), + } + } }; - let window_id = sess.current_window_id().await; - let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata( - sess.installation_id.clone(), - window_id, - CodexResponsesRequestKind::Compaction(compaction_metadata), - ); - let new_history = sess - .services - .model_client - .compact_conversation_history( - &prompt, - &turn_context.model_info, - turn_state, - CompactConversationRequestSettings { - effort: turn_context.reasoning_effort.clone(), - summary: turn_context.reasoning_summary, - service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) { - None - } else { - turn_context.config.service_tier.clone() - }, - }, - &turn_context.session_telemetry, - &compaction_trace, - &responses_metadata, - ) - .await?; + let RemoteCompactAttempt { + new_history, + trace_input_history, + } = attempt; let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await; let (new_history, world_state_baseline) = process_compacted_history( sess.as_ref(), - turn_context.as_ref(), + compaction_turn_context.as_ref(), new_history, &initial_context_injection, ) @@ -277,7 +268,7 @@ async fn run_remote_compact_task_inner_impl( let reference_context_item = match initial_context_injection { InitialContextInjection::DoNotInject => None, InitialContextInjection::BeforeLastUserMessage(_) => { - Some(turn_context.to_turn_context_item()) + Some(compaction_turn_context.to_turn_context_item()) } }; let compacted_item = CompactedItem { @@ -296,16 +287,16 @@ async fn run_remote_compact_task_inner_impl( replacement_history: &new_history, }); sess.replace_compacted_history( - turn_context.as_ref(), + compaction_turn_context.as_ref(), new_history, reference_context_item, world_state_baseline, compacted_item, ) .await; - sess.recompute_token_usage(turn_context).await; + sess.recompute_token_usage(compaction_turn_context).await; - sess.emit_turn_item_completed(turn_context, compaction_item) + sess.emit_turn_item_completed(compaction_turn_context, compaction_item) .await; Ok(()) } diff --git a/codex-rs/core/src/compact_remote_request.rs b/codex-rs/core/src/compact_remote_request.rs new file mode 100644 index 0000000000..bbf2f04446 --- /dev/null +++ b/codex-rs/core/src/compact_remote_request.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; +use std::sync::OnceLock; + +use super::trim_function_call_history_to_fit_context_window; +use crate::Prompt; +use crate::client::CompactConversationRequestSettings; +use crate::compact::CompactionAnalyticsDetails; +use crate::responses_metadata::CodexResponsesRequestKind; +use crate::responses_metadata::CompactionTurnMetadata; +use crate::session::session::Session; +use crate::session::step_context::StepContext; +use crate::session::turn::built_tools; +use codex_protocol::auth::AuthMode; +use codex_protocol::error::Result as CodexResult; +use codex_protocol::models::ResponseItem; +use codex_rollout_trace::CompactionTraceContext; +use tokio_util::sync::CancellationToken; +use tracing::info; + +pub(super) struct RemoteCompactAttempt { + pub(super) new_history: Vec, + pub(super) trace_input_history: Vec, +} + +pub(super) async fn run_remote_compact_attempt( + sess: &Arc, + step_context: &Arc, + turn_state: Option>>, + compaction_trace: &CompactionTraceContext, + compaction_metadata: CompactionTurnMetadata, + analytics_details: &mut CompactionAnalyticsDetails, +) -> CodexResult { + let turn_context = &step_context.turn; + let mut history = sess.clone_history().await; + let base_instructions = sess.get_base_instructions().await; + let (rewritten_outputs, estimated_deleted_tokens) = + trim_function_call_history_to_fit_context_window( + &mut history, + turn_context.as_ref(), + &base_instructions, + ); + if rewritten_outputs > 0 { + info!( + turn_id = %turn_context.sub_id, + rewritten_outputs, + "rewrote history outputs before remote compaction" + ); + } + if estimated_deleted_tokens > 0 { + let max_local_deleted_tokens = sess + .estimated_tokens_after_last_model_generated_item() + .await; + analytics_details.active_context_tokens_before = analytics_details + .active_context_tokens_before + .map(|active_context_tokens_before| { + active_context_tokens_before + .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)) + }); + } + let trace_input_history = history.raw_items().to_vec(); + let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities); + let tool_router = built_tools( + sess.as_ref(), + step_context.as_ref(), + &CancellationToken::new(), + ) + .await?; + let prompt = Prompt { + input: prompt_input, + tools: tool_router.model_visible_specs(), + parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, + base_instructions, + output_schema: None, + output_schema_strict: true, + }; + let window_id = sess.current_window_id().await; + let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata( + sess.installation_id.clone(), + window_id, + CodexResponsesRequestKind::Compaction(compaction_metadata), + ); + let new_history = sess + .services + .model_client + .compact_conversation_history( + &prompt, + &turn_context.model_info, + turn_state, + CompactConversationRequestSettings { + effort: turn_context.reasoning_effort.clone(), + summary: turn_context.reasoning_summary, + service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) { + None + } else { + turn_context.config.service_tier.clone() + }, + }, + &turn_context.session_telemetry, + compaction_trace, + &responses_metadata, + ) + .await?; + Ok(RemoteCompactAttempt { + new_history, + trace_input_history, + }) +} diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 6e110045bb..1571602cc1 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -8,21 +8,19 @@ use crate::compact::CompactionAnalyticsAttempt; use crate::compact::CompactionAnalyticsDetails; use crate::compact::InitialContextInjection; use crate::compact::compaction_status_from_result; +use crate::compact_model_fallback::record_model_fallback; use crate::compact_remote::process_compacted_history; use crate::compact_remote::should_keep_compacted_history_item; -use crate::compact_remote::trim_function_call_history_to_fit_context_window; use crate::hook_runtime::PostCompactHookOutcome; use crate::hook_runtime::PreCompactHookOutcome; use crate::hook_runtime::run_post_compact_hooks; use crate::hook_runtime::run_pre_compact_hooks; use crate::responses_metadata::CodexResponsesMetadata; -use crate::responses_metadata::CodexResponsesRequestKind; use crate::responses_metadata::CompactionTurnMetadata; use crate::responses_retry::ResponsesStreamRequest; use crate::responses_retry::handle_retryable_response_stream_error; use crate::session::session::Session; use crate::session::step_context::StepContext; -use crate::session::turn::built_tools; use crate::session::turn_context::TurnContext; use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; @@ -44,8 +42,11 @@ use codex_rollout_trace::InferenceTraceContext; use codex_utils_output_truncation::approx_token_count; use codex_utils_output_truncation::truncate_text; use futures::StreamExt; -use tokio_util::sync::CancellationToken; -use tracing::info; + +#[path = "compact_remote_v2_attempt.rs"] +mod attempt; +use attempt::RemoteCompactV2Attempt; +use attempt::run_remote_compact_v2_attempt; // Mirror the current /responses/compact retained-message default while the // server-side path remains the reference implementation. @@ -57,19 +58,25 @@ const MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES: u64 = 2; pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, step_context: Arc, + fallback_step_context: Option>, client_session: &mut ModelClientSession, initial_context_injection: InitialContextInjection, reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { + let compaction_metadata = CompactionTurnMetadata::new( + CompactionTrigger::Auto, + reason, + CompactionImplementation::ResponsesCompactionV2, + phase, + ); run_remote_compact_task_inner( &sess, &step_context, + fallback_step_context.as_ref(), Some(client_session), initial_context_injection, - CompactionTrigger::Auto, - reason, - phase, + compaction_metadata, ) .await } @@ -89,14 +96,19 @@ pub(crate) async fn run_remote_compact_task( }); sess.send_event(&turn_context, start_event).await; + let compaction_metadata = CompactionTurnMetadata::new( + CompactionTrigger::Manual, + CompactionReason::UserRequested, + CompactionImplementation::ResponsesCompactionV2, + CompactionPhase::StandaloneTurn, + ); run_remote_compact_task_inner( &sess, &step_context, + /*fallback_step_context*/ None, /*client_session*/ None, InitialContextInjection::DoNotInject, - CompactionTrigger::Manual, - CompactionReason::UserRequested, - CompactionPhase::StandaloneTurn, + compaction_metadata, ) .await } @@ -104,19 +116,16 @@ pub(crate) async fn run_remote_compact_task( async fn run_remote_compact_task_inner( sess: &Arc, step_context: &Arc, + fallback_step_context: Option<&Arc>, client_session: Option<&mut ModelClientSession>, initial_context_injection: InitialContextInjection, - trigger: CompactionTrigger, - reason: CompactionReason, - phase: CompactionPhase, + compaction_metadata: CompactionTurnMetadata, ) -> CodexResult<()> { let turn_context = &step_context.turn; - let compaction_metadata = CompactionTurnMetadata::new( - trigger, - reason, - CompactionImplementation::ResponsesCompactionV2, - phase, - ); + let trigger = compaction_metadata.trigger(); + let reason = compaction_metadata.reason(); + let implementation = compaction_metadata.implementation(); + let phase = compaction_metadata.phase(); let mut analytics_details = CompactionAnalyticsDetails { active_context_tokens_before: Some(sess.get_total_token_usage().await), ..Default::default() @@ -126,7 +135,7 @@ async fn run_remote_compact_task_inner( turn_context.as_ref(), trigger, reason, - CompactionImplementation::ResponsesCompactionV2, + implementation, phase, ) .await; @@ -149,6 +158,7 @@ async fn run_remote_compact_task_inner( let result = run_remote_compact_task_inner_impl( sess, step_context, + fallback_step_context, client_session, initial_context_injection, compaction_metadata, @@ -186,16 +196,18 @@ async fn run_remote_compact_task_inner( async fn run_remote_compact_task_inner_impl( sess: &Arc, step_context: &Arc, - client_session: Option<&mut ModelClientSession>, + fallback_step_context: Option<&Arc>, + mut client_session: Option<&mut ModelClientSession>, initial_context_injection: InitialContextInjection, compaction_metadata: CompactionTurnMetadata, analytics_details: &mut CompactionAnalyticsDetails, ) -> CodexResult<()> { let turn_context = &step_context.turn; let context_compaction_item = ContextCompactionItem::new(); + let compaction_id = context_compaction_item.id.clone(); let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( turn_context.sub_id.as_str(), - context_compaction_item.id.as_str(), + compaction_id.as_str(), turn_context.model_info.slug.as_str(), turn_context.provider.info().name.as_str(), ); @@ -203,91 +215,62 @@ async fn run_remote_compact_task_inner_impl( sess.emit_turn_item_started(turn_context, &compaction_item) .await; - let mut history = sess.clone_history().await; - let base_instructions = sess.get_base_instructions().await; - let (rewritten_outputs, estimated_deleted_tokens) = - trim_function_call_history_to_fit_context_window( - &mut history, - turn_context.as_ref(), - &base_instructions, - ); - if rewritten_outputs > 0 { - info!( - turn_id = %turn_context.sub_id, - rewritten_outputs, - "rewrote history outputs before remote compaction v2" - ); - } - if estimated_deleted_tokens > 0 { - let max_local_deleted_tokens = sess - .estimated_tokens_after_last_model_generated_item() - .await; - analytics_details.active_context_tokens_before = analytics_details - .active_context_tokens_before - .map(|active_context_tokens_before| { - active_context_tokens_before - .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)) - }); - } - - let trace_input_history = history.raw_items().to_vec(); - let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities); - let tool_router = built_tools( - sess.as_ref(), - step_context.as_ref(), - &CancellationToken::new(), - ) - .await?; - let mut input = prompt_input.clone(); - input.push(ResponseItem::CompactionTrigger {}); - let prompt = Prompt { - input, - tools: tool_router.model_visible_specs(), - parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, - base_instructions, - output_schema: None, - output_schema_strict: true, - }; - - let window_id = sess.current_window_id().await; - let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata( - sess.installation_id.clone(), - window_id, - CodexResponsesRequestKind::Compaction(compaction_metadata), - ); - let trace_attempt = compaction_trace.start_attempt(&serde_json::json!({ - "model": turn_context.model_info.slug.as_str(), - "instructions": prompt.base_instructions.text.as_str(), - "input": &prompt.input, - "parallel_tool_calls": prompt.parallel_tool_calls, - })); - - let mut owned_client_session; - let client_session = match client_session { - Some(client_session) => client_session, - None => { - owned_client_session = sess.services.model_client.new_session(); - &mut owned_client_session - } - }; - let compaction_output_result = run_remote_compaction_request_v2( + let attempt = run_remote_compact_v2_attempt( sess, - turn_context, - client_session, - &prompt, - &responses_metadata, + step_context, + client_session.as_deref_mut(), + &compaction_trace, + compaction_metadata, + analytics_details, ) .await; - - trace_attempt.record_result( - compaction_output_result - .as_ref() - .map(|output| std::slice::from_ref(&output.compaction_output)), - ); - let RemoteCompactionV2Output { + let (attempt, compaction_turn_context) = match attempt { + Ok(attempt) => (attempt, turn_context), + Err(error) => { + let Some(fallback_step_context) = fallback_step_context else { + return Err(error); + }; + if !matches!(&error, CodexErr::InvalidRequest(_)) { + return Err(error); + } + let fallback_turn_context = &fallback_step_context.turn; + let fallback_compaction_trace = + sess.services.rollout_thread_trace.compaction_trace_context( + fallback_turn_context.sub_id.as_str(), + compaction_id.as_str(), + fallback_turn_context.model_info.slug.as_str(), + fallback_turn_context.provider.info().name.as_str(), + ); + let fallback_result = run_remote_compact_v2_attempt( + sess, + fallback_step_context, + client_session, + &fallback_compaction_trace, + compaction_metadata, + analytics_details, + ) + .await; + record_model_fallback( + &sess.services.session_telemetry, + turn_context.model_info.slug.as_str(), + fallback_turn_context.model_info.slug.as_str(), + compaction_metadata.reason(), + compaction_metadata.implementation(), + fallback_result.as_ref().err(), + ); + match fallback_result { + Ok(attempt) => (attempt, fallback_turn_context), + Err(_) => return Err(error), + } + } + }; + let RemoteCompactV2Attempt { + trace_input_history, + prompt_input, compaction_output, token_usage, - } = compaction_output_result?; + owned_client_session: _owned_client_session, + } = attempt; if let Some(token_usage) = token_usage { sess.record_rollout_budget_usage(&token_usage)?; analytics_details.active_context_tokens_before = Some(token_usage.input_tokens); @@ -300,7 +283,7 @@ async fn run_remote_compact_task_inner_impl( let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await; let (new_history, world_state_baseline) = process_compacted_history( sess.as_ref(), - turn_context.as_ref(), + compaction_turn_context.as_ref(), compacted_history, &initial_context_injection, ) @@ -309,7 +292,7 @@ async fn run_remote_compact_task_inner_impl( let reference_context_item = match initial_context_injection { InitialContextInjection::DoNotInject => None, InitialContextInjection::BeforeLastUserMessage(_) => { - Some(turn_context.to_turn_context_item()) + Some(compaction_turn_context.to_turn_context_item()) } }; let compacted_item = CompactedItem { @@ -325,16 +308,16 @@ async fn run_remote_compact_task_inner_impl( replacement_history: &new_history, }); sess.replace_compacted_history( - turn_context.as_ref(), + compaction_turn_context.as_ref(), new_history, reference_context_item, world_state_baseline, compacted_item, ) .await; - sess.recompute_token_usage(turn_context).await; + sess.recompute_token_usage(compaction_turn_context).await; - sess.emit_turn_item_completed(turn_context, compaction_item) + sess.emit_turn_item_completed(compaction_turn_context, compaction_item) .await; Ok(()) } diff --git a/codex-rs/core/src/compact_remote_v2_attempt.rs b/codex-rs/core/src/compact_remote_v2_attempt.rs new file mode 100644 index 0000000000..a145d00ae6 --- /dev/null +++ b/codex-rs/core/src/compact_remote_v2_attempt.rs @@ -0,0 +1,126 @@ +use std::sync::Arc; + +use super::RemoteCompactionV2Output; +use super::run_remote_compaction_request_v2; +use crate::Prompt; +use crate::client::ModelClientSession; +use crate::compact::CompactionAnalyticsDetails; +use crate::compact_remote::trim_function_call_history_to_fit_context_window; +use crate::responses_metadata::CodexResponsesRequestKind; +use crate::responses_metadata::CompactionTurnMetadata; +use crate::session::session::Session; +use crate::session::step_context::StepContext; +use crate::session::turn::built_tools; +use codex_protocol::error::Result as CodexResult; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::TokenUsage; +use codex_rollout_trace::CompactionTraceContext; +use tokio_util::sync::CancellationToken; +use tracing::info; + +pub(super) struct RemoteCompactV2Attempt { + pub(super) trace_input_history: Vec, + pub(super) prompt_input: Vec, + pub(super) compaction_output: ResponseItem, + pub(super) token_usage: Option, + /// Keeps a session created for standalone compaction alive through lifecycle completion. + pub(super) owned_client_session: Option, +} + +pub(super) async fn run_remote_compact_v2_attempt( + sess: &Arc, + step_context: &Arc, + client_session: Option<&mut ModelClientSession>, + compaction_trace: &CompactionTraceContext, + compaction_metadata: CompactionTurnMetadata, + analytics_details: &mut CompactionAnalyticsDetails, +) -> CodexResult { + let turn_context = &step_context.turn; + let mut history = sess.clone_history().await; + let base_instructions = sess.get_base_instructions().await; + let (rewritten_outputs, estimated_deleted_tokens) = + trim_function_call_history_to_fit_context_window( + &mut history, + turn_context.as_ref(), + &base_instructions, + ); + if rewritten_outputs > 0 { + info!( + turn_id = %turn_context.sub_id, + rewritten_outputs, + "rewrote history outputs before remote compaction v2" + ); + } + if estimated_deleted_tokens > 0 { + let max_local_deleted_tokens = sess + .estimated_tokens_after_last_model_generated_item() + .await; + analytics_details.active_context_tokens_before = analytics_details + .active_context_tokens_before + .map(|active_context_tokens_before| { + active_context_tokens_before + .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)) + }); + } + + let trace_input_history = history.raw_items().to_vec(); + let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities); + let tool_router = built_tools( + sess.as_ref(), + step_context.as_ref(), + &CancellationToken::new(), + ) + .await?; + let mut input = prompt_input.clone(); + input.push(ResponseItem::CompactionTrigger {}); + let prompt = Prompt { + input, + tools: tool_router.model_visible_specs(), + parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, + base_instructions, + output_schema: None, + output_schema_strict: true, + }; + + let window_id = sess.current_window_id().await; + let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata( + sess.installation_id.clone(), + window_id, + CodexResponsesRequestKind::Compaction(compaction_metadata), + ); + let trace_attempt = compaction_trace.start_attempt(&serde_json::json!({ + "model": turn_context.model_info.slug.as_str(), + "instructions": prompt.base_instructions.text.as_str(), + "input": &prompt.input, + "parallel_tool_calls": prompt.parallel_tool_calls, + })); + let mut owned_client_session = None; + let client_session = match client_session { + Some(client_session) => client_session, + None => owned_client_session.insert(sess.services.model_client.new_session()), + }; + let compaction_output_result = run_remote_compaction_request_v2( + sess, + turn_context.as_ref(), + client_session, + &prompt, + &responses_metadata, + ) + .await; + trace_attempt.record_result( + compaction_output_result + .as_ref() + .map(|output| std::slice::from_ref(&output.compaction_output)), + ); + let RemoteCompactionV2Output { + compaction_output, + token_usage, + } = compaction_output_result?; + Ok(RemoteCompactV2Attempt { + trace_input_history, + prompt_input, + compaction_output, + token_usage, + owned_client_session, + }) +} diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 6156b9b9ea..0c8d0899a4 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1590,6 +1590,10 @@ respect_system_proxy = true .await?; assert!(config.respect_system_proxy); + assert_eq!( + config.http_client_factory().outbound_proxy_policy(), + codex_http_client::OutboundProxyPolicy::RespectSystemProxy + ); Ok(()) } @@ -6035,13 +6039,6 @@ async fn to_mcp_config_preserves_auth_elicitation_feature_from_config() -> std:: .await?; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); - let mcp_config = config.to_mcp_config(&plugins_manager).await; - assert_eq!( - mcp_config.client_elicitation_capability, - ElicitationCapability::default() - ); - - let _ = config.features.enable(Feature::AuthElicitation); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert_eq!( mcp_config.client_elicitation_capability, @@ -6051,6 +6048,13 @@ async fn to_mcp_config_preserves_auth_elicitation_feature_from_config() -> std:: } ); + let _ = config.features.disable(Feature::AuthElicitation); + let mcp_config = config.to_mcp_config(&plugins_manager).await; + assert_eq!( + mcp_config.client_elicitation_capability, + ElicitationCapability::default() + ); + Ok(()) } diff --git a/codex-rs/core/src/config/edit/document_helpers.rs b/codex-rs/core/src/config/edit/document_helpers.rs index 308eb9922f..871ca0e41e 100644 --- a/codex-rs/core/src/config/edit/document_helpers.rs +++ b/codex-rs/core/src/config/edit/document_helpers.rs @@ -121,6 +121,7 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable { entry["default_tools_approval_mode"] = value(match approval_mode { AppToolApproval::Auto => "auto", AppToolApproval::Prompt => "prompt", + AppToolApproval::Writes => "writes", AppToolApproval::Approve => "approve", }); } @@ -173,6 +174,7 @@ fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem { entry["approval_mode"] = value(match approval_mode { AppToolApproval::Auto => "auto", AppToolApproval::Prompt => "prompt", + AppToolApproval::Writes => "writes", AppToolApproval::Approve => "approve", }); } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index e29cb3875c..8c0a165564 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -52,6 +52,7 @@ use codex_config::types::TuiNotificationSettings; use codex_config::types::TuiPetAnchor; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; +use codex_core_plugins::PluginLoadOutcome; use codex_core_plugins::PluginsConfigInput; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::LOCAL_FS; @@ -69,6 +70,8 @@ use codex_features::MultiAgentV2ConfigToml; use codex_features::NetworkProxyConfigToml; use codex_features::TokenBudgetConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; use codex_install_context::InstallContext; use codex_login::AuthManagerConfig; use codex_login::AuthRouteConfig; @@ -1480,6 +1483,16 @@ impl Config { .then(AuthRouteConfig::respect_system_proxy) } + /// Creates the HTTP client factory resolved from the effective feature configuration. + pub fn http_client_factory(&self) -> HttpClientFactory { + let outbound_proxy_policy = if self.respect_system_proxy { + OutboundProxyPolicy::RespectSystemProxy + } else { + OutboundProxyPolicy::ReqwestDefault + }; + HttpClientFactory::new(outbound_proxy_policy) + } + /// Build the plugin-manager input from the effective config. pub fn plugins_config_input(&self) -> PluginsConfigInput { PluginsConfigInput::new( @@ -1528,6 +1541,14 @@ impl Config { ) -> McpConfig { let plugins_input = self.plugins_config_input(); let loaded_plugins = plugins_manager.plugins_for_config(&plugins_input).await; + self.to_mcp_config_with_loaded_plugins(&loaded_plugins, additional_plugin_registrations) + } + + pub(crate) fn to_mcp_config_with_loaded_plugins( + &self, + loaded_plugins: &PluginLoadOutcome, + additional_plugin_registrations: impl IntoIterator, + ) -> McpConfig { let mut catalog = ResolvedMcpCatalog::builder(); for (plugin_order, plugin) in loaded_plugins .plugins() diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index b45cc841a1..6e643f34d5 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -80,19 +80,6 @@ pub async fn list_accessible_connectors_from_mcp_tools( ) } -pub(crate) async fn list_accessible_and_enabled_connectors_from_manager( - mcp_connection_manager: &McpConnectionManager, - config: &Config, -) -> Vec { - with_app_enabled_state( - accessible_connectors_from_mcp_tools(&mcp_connection_manager.list_all_tools().await), - config, - ) - .into_iter() - .filter(|connector| connector.is_accessible && connector.is_enabled) - .collect() -} - #[instrument(level = "trace", skip_all)] pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( config: &Config, @@ -283,6 +270,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, + /*elicitation_lifecycle*/ None, codex_mcp::ElicitationRequestRouter::default(), ) .await; diff --git a/codex-rs/core/src/context/apps_instructions.rs b/codex-rs/core/src/context/apps_instructions.rs index 761187c794..71d836d4ea 100644 --- a/codex-rs/core/src/context/apps_instructions.rs +++ b/codex-rs/core/src/context/apps_instructions.rs @@ -1,4 +1,3 @@ -use crate::connectors::AppInfo; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG; @@ -8,15 +7,6 @@ use super::ContextualUserFragment; #[derive(Debug, Clone, PartialEq)] pub(crate) struct AppsInstructions; -impl AppsInstructions { - pub(crate) fn from_connectors(connectors: &[AppInfo]) -> Option { - connectors - .iter() - .any(|connector| connector.is_accessible && connector.is_enabled) - .then_some(Self) - } -} - impl ContextualUserFragment for AppsInstructions { fn role(&self) -> &'static str { "developer" diff --git a/codex-rs/core/src/context/available_plugins_instructions.rs b/codex-rs/core/src/context/available_plugins_instructions.rs index 683d939aa0..a31d61d7fd 100644 --- a/codex-rs/core/src/context/available_plugins_instructions.rs +++ b/codex-rs/core/src/context/available_plugins_instructions.rs @@ -1,25 +1,10 @@ -use codex_plugin::PluginCapabilitySummary; use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG; use super::ContextualUserFragment; #[derive(Debug, Clone, PartialEq)] -pub(crate) struct AvailablePluginsInstructions { - plugins: Vec, -} - -impl AvailablePluginsInstructions { - pub(crate) fn from_plugins(plugins: &[PluginCapabilitySummary]) -> Option { - if plugins.is_empty() { - return None; - } - - Some(Self { - plugins: plugins.to_vec(), - }) - } -} +pub(crate) struct AvailablePluginsInstructions; impl ContextualUserFragment for AvailablePluginsInstructions { fn role(&self) -> &'static str { diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index da7db0b373..20b35e7af3 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -62,6 +62,7 @@ pub(crate) use legacy_unified_exec_process_limit_warning::LegacyUnifiedExecProce pub(crate) use model_switch_instructions::ModelSwitchInstructions; pub(crate) use multi_agent_mode_instructions::MultiAgentModeInstructions; pub(crate) use network_rule_saved::NetworkRuleSaved; +pub use permissions_instructions::ApprovalPromptContext; pub use permissions_instructions::PermissionsInstructions; pub(crate) use personality_spec_instructions::PersonalitySpecInstructions; pub(crate) use plugin_instructions::PluginInstructions; diff --git a/codex-rs/core/src/context/permissions_instructions.rs b/codex-rs/core/src/context/permissions_instructions.rs index 73629a3d53..3dd1bbac92 100644 --- a/codex-rs/core/src/context/permissions_instructions.rs +++ b/codex-rs/core/src/context/permissions_instructions.rs @@ -1 +1,2 @@ +pub use codex_prompts::ApprovalPromptContext; pub use codex_prompts::PermissionsInstructions; diff --git a/codex-rs/core/src/context/world_state/apps_instructions.rs b/codex-rs/core/src/context/world_state/apps_instructions.rs new file mode 100644 index 0000000000..767e1c584e --- /dev/null +++ b/codex-rs/core/src/context/world_state/apps_instructions.rs @@ -0,0 +1,55 @@ +use super::PreviousSectionState; +use super::WorldStateSection; +use crate::context::AppsInstructions; +use crate::context::ContextualUserFragment; + +/// Whether generic Apps usage guidance should be visible to the model. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct AppsInstructionsState { + available: bool, +} + +impl AppsInstructionsState { + pub(crate) fn new(available: bool) -> Self { + Self { available } + } +} + +impl WorldStateSection for AppsInstructionsState { + const ID: &'static str = "apps_instructions"; + type Snapshot = bool; + + fn snapshot(&self) -> Self::Snapshot { + self.available + } + + fn matches_legacy_fragment(role: &str, text: &str) -> bool { + role == "developer" && AppsInstructions::matches_text(text) + } + + fn has_retained_fragment_matcher() -> bool { + true + } + + fn matches_retained_fragment(role: &str, text: &str) -> bool { + Self::matches_legacy_fragment(role, text) + } + + fn render_diff( + &self, + previous: PreviousSectionState<'_, Self::Snapshot>, + ) -> Option> { + if !self.available + || matches!(previous, PreviousSectionState::Known(previous) if *previous) + || matches!(previous, PreviousSectionState::Unknown) + { + return None; + } + + Some(Box::new(AppsInstructions)) + } +} + +#[cfg(test)] +#[path = "apps_instructions_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/context/world_state/apps_instructions_tests.rs b/codex-rs/core/src/context/world_state/apps_instructions_tests.rs new file mode 100644 index 0000000000..92f6613594 --- /dev/null +++ b/codex-rs/core/src/context/world_state/apps_instructions_tests.rs @@ -0,0 +1,69 @@ +use super::*; +use crate::context::ContextualUserFragment; +use codex_protocol::models::ResponseItem; +use pretty_assertions::assert_eq; + +fn render(state: AppsInstructionsState, previous: PreviousSectionState<'_, bool>) -> Vec { + state + .render_diff(previous) + .into_iter() + .map(|fragment| fragment.render()) + .collect() +} + +#[test] +fn renders_only_when_apps_become_available() { + let unavailable = AppsInstructionsState::new(/*available*/ false); + let available = AppsInstructionsState::new(/*available*/ true); + let false_snapshot = false; + let true_snapshot = true; + + assert_eq!( + render(unavailable, PreviousSectionState::Absent), + Vec::::new() + ); + assert_eq!(render(available, PreviousSectionState::Absent).len(), 1); + assert_eq!( + render(available, PreviousSectionState::Known(&false_snapshot)).len(), + 1 + ); + assert_eq!( + render(available, PreviousSectionState::Known(&true_snapshot)), + Vec::::new() + ); + assert_eq!( + render(unavailable, PreviousSectionState::Known(&true_snapshot)), + Vec::::new() + ); +} + +#[test] +fn legacy_guidance_is_not_injected_again() { + let mut world_state = super::super::WorldState::default(); + world_state.add_section(AppsInstructionsState::new(/*available*/ true)); + let legacy: ResponseItem = ContextualUserFragment::into(AppsInstructions); + + assert!( + world_state + .render_history_diff(/*previous*/ None, &[legacy]) + .is_empty() + ); +} + +#[test] +fn persisted_guidance_is_restored_only_when_missing_from_history() { + let mut world_state = super::super::WorldState::default(); + world_state.add_section(AppsInstructionsState::new(/*available*/ true)); + let snapshot = world_state.snapshot(); + let retained: ResponseItem = ContextualUserFragment::into(AppsInstructions); + + assert_eq!( + world_state.render_history_diff(Some(&snapshot), &[]).len(), + 1 + ); + assert!( + world_state + .render_history_diff(Some(&snapshot), &[retained]) + .is_empty() + ); +} diff --git a/codex-rs/core/src/context/world_state/mod.rs b/codex-rs/core/src/context/world_state/mod.rs index ea82ddd11f..ccdc976c4c 100644 --- a/codex-rs/core/src/context/world_state/mod.rs +++ b/codex-rs/core/src/context/world_state/mod.rs @@ -1,5 +1,7 @@ mod agents_md; +mod apps_instructions; mod environment; +mod plugins_instructions; use crate::context::ContextualUserFragment; use codex_extension_api::PreviousWorldStateSection; @@ -16,7 +18,9 @@ use std::collections::BTreeMap; use std::fmt; pub(crate) use agents_md::AgentsMdState; +pub(crate) use apps_instructions::AppsInstructionsState; pub(crate) use environment::EnvironmentsState; +pub(crate) use plugins_instructions::PluginsInstructionsState; trait ErasedWorldStateSection: Send + Sync { fn snapshot(&self) -> Option; @@ -62,11 +66,11 @@ impl ErasedWorldStateSection for S { } fn has_retained_fragment_matcher(&self) -> bool { - false + S::has_retained_fragment_matcher() } - fn matches_retained_fragment(&self, _role: &str, _text: &str) -> bool { - false + fn matches_retained_fragment(&self, role: &str, text: &str) -> bool { + S::matches_retained_fragment(role, text) } fn render_diff( @@ -183,6 +187,16 @@ pub(crate) trait WorldStateSection: Send + Sync + 'static { false } + /// Whether retained history must still contain this section's rendered fragment. + fn has_retained_fragment_matcher() -> bool { + false + } + + /// Recognizes this section's rendered fragment in retained model history. + fn matches_retained_fragment(_role: &str, _text: &str) -> bool { + false + } + fn render_diff( &self, previous: PreviousSectionState<'_, Self::Snapshot>, diff --git a/codex-rs/core/src/context/world_state/plugins_instructions.rs b/codex-rs/core/src/context/world_state/plugins_instructions.rs new file mode 100644 index 0000000000..303307317d --- /dev/null +++ b/codex-rs/core/src/context/world_state/plugins_instructions.rs @@ -0,0 +1,55 @@ +use super::PreviousSectionState; +use super::WorldStateSection; +use crate::context::AvailablePluginsInstructions; +use crate::context::ContextualUserFragment; + +/// Whether generic plugin usage guidance should be visible to the model. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct PluginsInstructionsState { + available: bool, +} + +impl PluginsInstructionsState { + pub(crate) fn new(available: bool) -> Self { + Self { available } + } +} + +impl WorldStateSection for PluginsInstructionsState { + const ID: &'static str = "plugins_instructions"; + type Snapshot = bool; + + fn snapshot(&self) -> Self::Snapshot { + self.available + } + + fn matches_legacy_fragment(role: &str, text: &str) -> bool { + role == "developer" && AvailablePluginsInstructions::matches_text(text) + } + + fn has_retained_fragment_matcher() -> bool { + true + } + + fn matches_retained_fragment(role: &str, text: &str) -> bool { + Self::matches_legacy_fragment(role, text) + } + + fn render_diff( + &self, + previous: PreviousSectionState<'_, Self::Snapshot>, + ) -> Option> { + if !self.available + || matches!(previous, PreviousSectionState::Known(previous) if *previous) + || matches!(previous, PreviousSectionState::Unknown) + { + return None; + } + + Some(Box::new(AvailablePluginsInstructions)) + } +} + +#[cfg(test)] +#[path = "plugins_instructions_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/context/world_state/plugins_instructions_tests.rs b/codex-rs/core/src/context/world_state/plugins_instructions_tests.rs new file mode 100644 index 0000000000..a688ff93ca --- /dev/null +++ b/codex-rs/core/src/context/world_state/plugins_instructions_tests.rs @@ -0,0 +1,72 @@ +use super::*; +use crate::context::ContextualUserFragment; +use codex_protocol::models::ResponseItem; +use pretty_assertions::assert_eq; + +fn render( + state: PluginsInstructionsState, + previous: PreviousSectionState<'_, bool>, +) -> Vec { + state + .render_diff(previous) + .into_iter() + .map(|fragment| fragment.render()) + .collect() +} + +#[test] +fn renders_only_when_plugins_become_available() { + let unavailable = PluginsInstructionsState::new(/*available*/ false); + let available = PluginsInstructionsState::new(/*available*/ true); + let false_snapshot = false; + let true_snapshot = true; + + assert_eq!( + render(unavailable, PreviousSectionState::Absent), + Vec::::new() + ); + assert_eq!(render(available, PreviousSectionState::Absent).len(), 1); + assert_eq!( + render(available, PreviousSectionState::Known(&false_snapshot)).len(), + 1 + ); + assert_eq!( + render(available, PreviousSectionState::Known(&true_snapshot)), + Vec::::new() + ); + assert_eq!( + render(unavailable, PreviousSectionState::Known(&true_snapshot)), + Vec::::new() + ); +} + +#[test] +fn legacy_guidance_is_not_injected_again() { + let mut world_state = super::super::WorldState::default(); + world_state.add_section(PluginsInstructionsState::new(/*available*/ true)); + let legacy: ResponseItem = ContextualUserFragment::into(AvailablePluginsInstructions); + + assert!( + world_state + .render_history_diff(/*previous*/ None, &[legacy]) + .is_empty() + ); +} + +#[test] +fn persisted_guidance_is_restored_only_when_missing_from_history() { + let mut world_state = super::super::WorldState::default(); + world_state.add_section(PluginsInstructionsState::new(/*available*/ true)); + let snapshot = world_state.snapshot(); + let retained: ResponseItem = ContextualUserFragment::into(AvailablePluginsInstructions); + + assert_eq!( + world_state.render_history_diff(Some(&snapshot), &[]).len(), + 1 + ); + assert!( + world_state + .render_history_diff(Some(&snapshot), &[retained]) + .is_empty() + ); +} diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index cbe0209e21..679e1a69f7 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -20,8 +20,10 @@ use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::default_input_modalities; +use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::InterAgentCommunication; +use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnContextItem; use codex_utils_absolute_path::AbsolutePathBuf; @@ -1021,6 +1023,12 @@ fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() { user_input_text_msg("turn 1 user"), assistant_msg("turn 1 assistant"), developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."), + developer_msg(&format!( + "{APPS_INSTRUCTIONS_OPEN_TAG}\nROLLED_BACK_APPS_INSTRUCTIONS" + )), + developer_msg(&format!( + "{PLUGINS_INSTRUCTIONS_OPEN_TAG}\nROLLED_BACK_PLUGIN_INSTRUCTIONS" + )), developer_msg("ROLLED_BACK_DEV_INSTRUCTIONS"), developer_msg("ROLLED_BACK_MULTI_AGENT_MODE"), user_input_text_msg( diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index a520e6f595..d4cf65e86d 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -1,3 +1,4 @@ +use crate::context::ApprovalPromptContext; use crate::context::CollaborationModeInstructions; use crate::context::ContextualUserFragment; use crate::context::ModelSwitchInstructions; @@ -30,6 +31,7 @@ fn build_permissions_update_item( let prev = previous?; if prev.permission_profile() == next.permission_profile() && prev.approval_policy == next.approval_policy.value() + && prev.model == next.model_info.slug { return None; } @@ -38,7 +40,13 @@ fn build_permissions_update_item( PermissionsInstructions::from_permission_profile( &next.permission_profile, next.approval_policy.value(), - next.config.approvals_reviewer, + ApprovalPromptContext::new( + next.config.approvals_reviewer, + next.model_info + .model_messages + .as_ref() + .and_then(|messages| messages.approvals.as_ref()), + ), exec_policy, #[allow(deprecated)] &next.cwd, diff --git a/codex-rs/core/src/elicitation.rs b/codex-rs/core/src/elicitation.rs new file mode 100644 index 0000000000..b29207af46 --- /dev/null +++ b/codex-rs/core/src/elicitation.rs @@ -0,0 +1,100 @@ +use std::sync::Arc; +use std::sync::Mutex; + +use tokio::sync::watch; + +/// Coordinates user elicitations that pause tool-result delivery for a session. +/// +/// Registrations are counted so concurrent elicitations keep the session paused until all of them +/// finish. Consumers can subscribe to pause timeout progress or wait before returning an already +/// captured result. +#[derive(Clone)] +pub(crate) struct ElicitationService { + inner: Arc, +} + +struct Inner { + state: Mutex, + paused: watch::Sender, +} + +#[derive(Default)] +struct State { + outstanding: i64, +} + +pub(crate) struct ElicitationRegistration { + service: ElicitationService, +} + +impl ElicitationService { + pub(crate) fn new() -> Self { + let (paused, _paused_rx) = watch::channel(false); + Self { + inner: Arc::new(Inner { + state: Mutex::new(State::default()), + paused, + }), + } + } + + pub(crate) fn register(&self) -> ElicitationRegistration { + self.increment(); + ElicitationRegistration { + service: self.clone(), + } + } + + fn increment(&self) { + let mut state = self + .inner + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let was_clear = state.outstanding == 0; + assert_ne!( + state.outstanding, + i64::MAX, + "outstanding elicitation count overflowed" + ); + state.outstanding += 1; + if was_clear { + self.inner.paused.send_replace(true); + } + } + + pub(crate) fn subscribe(&self) -> watch::Receiver { + self.inner.paused.subscribe() + } + + pub(crate) async fn wait_until_clear(&self) { + let mut paused = self.subscribe(); + let _ = paused.wait_for(|paused| !*paused).await; + } + + fn decrement(&self) { + let mut state = self + .inner + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert!( + state.outstanding > 0, + "elicitation registration count underflowed" + ); + state.outstanding -= 1; + if state.outstanding == 0 { + self.inner.paused.send_replace(false); + } + } +} + +impl Drop for ElicitationRegistration { + fn drop(&mut self) { + self.service.decrement(); + } +} + +#[cfg(test)] +#[path = "elicitation_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/elicitation_tests.rs b/codex-rs/core/src/elicitation_tests.rs new file mode 100644 index 0000000000..eefe1f5394 --- /dev/null +++ b/codex-rs/core/src/elicitation_tests.rs @@ -0,0 +1,19 @@ +use super::*; + +#[tokio::test] +async fn wait_until_clear_waits_for_every_registration() { + let service = ElicitationService::new(); + let first = service.register(); + let second = service.register(); + let waiting = tokio::spawn({ + let service = service.clone(); + async move { service.wait_until_clear().await } + }); + + drop(first); + tokio::task::yield_now().await; + assert!(!waiting.is_finished()); + + drop(second); + waiting.await.expect("elicitation waiter should complete"); +} diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 392203c079..ef8f0fad5b 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -14,10 +14,12 @@ use codex_protocol::models::is_image_close_tag_text; use codex_protocol::models::is_image_open_tag_text; use codex_protocol::models::is_local_image_close_tag_text; use codex_protocol::models::is_local_image_open_tag_text; +use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG; use codex_protocol::protocol::CONTEXT_WINDOW_GUIDANCE_OPEN_TAG; use codex_protocol::protocol::CONTEXT_WINDOW_OPEN_TAG; use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; +use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::user_input::UserInput; @@ -31,8 +33,10 @@ use crate::web_search::web_search_action_detail; const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[ "", "", + APPS_INSTRUCTIONS_OPEN_TAG, COLLABORATION_MODE_OPEN_TAG, MULTI_AGENT_MODE_OPEN_TAG, + PLUGINS_INSTRUCTIONS_OPEN_TAG, REALTIME_CONVERSATION_OPEN_TAG, SKILLS_INSTRUCTIONS_OPEN_TAG, "", diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index cbffcd7e43..8f31f150b3 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -554,12 +554,19 @@ pub fn format_exec_policy_error_with_source(error: &ExecPolicyError) -> String { } } -async fn load_exec_policy_with_warning( +pub(crate) async fn load_exec_policy_with_warning( config_stack: &ConfigLayerStack, ) -> Result<(Policy, Option), ExecPolicyError> { match load_exec_policy(config_stack).await { Ok(policy) => Ok((policy, None)), - Err(err @ ExecPolicyError::ParsePolicy { .. }) => Ok((Policy::empty(), Some(err))), + Err(err @ ExecPolicyError::ParsePolicy { .. }) => { + let policy = config_stack + .requirements() + .exec_policy + .as_deref() + .map_or_else(Policy::empty, |policy| policy.as_ref().clone()); + Ok((policy, Some(err))) + } Err(err) => Err(err), } } diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index cf17387ef5..95d29e0bac 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -374,6 +374,42 @@ async fn merges_requirements_exec_policy_network_rules() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn malformed_custom_rules_preserve_requirements_exec_policy() -> anyhow::Result<()> { + let temp_dir = tempdir()?; + let policy_dir = temp_dir.path().join(RULES_DIR_NAME); + fs::create_dir_all(&policy_dir)?; + fs::write(policy_dir.join("broken.rules"), "prefix_rule(")?; + + let mut requirements_exec_policy = Policy::empty(); + requirements_exec_policy.add_prefix_rule(&["rm".to_string()], Decision::Forbidden)?; + let requirements = ConfigRequirements { + exec_policy: Some(Sourced::new( + RequirementsExecPolicy::new(requirements_exec_policy), + RequirementSource::Unknown, + )), + ..ConfigRequirements::default() + }; + let dot_codex_folder = AbsolutePathBuf::from_absolute_path(temp_dir.path())?; + let layer = ConfigLayerEntry::new( + ConfigLayerSource::Project { dot_codex_folder }, + TomlValue::Table(Default::default()), + ); + let config_stack = + ConfigLayerStack::new(vec![layer], requirements, ConfigRequirementsToml::default())?; + + let (policy, warning) = load_exec_policy_with_warning(&config_stack).await?; + + assert!(matches!(warning, Some(ExecPolicyError::ParsePolicy { .. }))); + assert_eq!( + policy + .check_multiple([vec!["rm".to_string()]].iter(), &|_| Decision::Allow) + .decision, + Decision::Forbidden + ); + Ok(()) +} + #[tokio::test] async fn preserves_host_executables_when_requirements_overlay_is_present() -> anyhow::Result<()> { let temp_dir = tempdir()?; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index df8753a386..e57288f05d 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -19,6 +19,7 @@ pub use responses_metadata::CodexResponsesMetadata; pub use session::SteerInputError; pub use turn_metadata::detached_memory_responses_metadata; mod codex_thread; +mod compact_model_fallback; mod compact_remote; mod compact_remote_v2; mod compact_token_budget; @@ -40,6 +41,7 @@ pub mod connectors; pub mod context; mod context_manager; mod current_time; +mod elicitation; mod environment_selection; pub mod exec; pub mod exec_env; diff --git a/codex-rs/core/src/mcp.rs b/codex-rs/core/src/mcp.rs index 45ca0d563d..835c47ff51 100644 --- a/codex-rs/core/src/mcp.rs +++ b/codex-rs/core/src/mcp.rs @@ -25,6 +25,12 @@ use codex_plugin::AppConnectorId; const LEGACY_CODEX_APPS_REGISTRATION_ID: &str = "legacy_codex_apps"; +/// MCP configuration and capability availability derived from the same inputs. +pub(crate) struct McpRuntimeProjection { + pub(crate) config: McpConfig, + pub(crate) plugins_available: bool, +} + enum OrderedMcpOverlay { Set { contributor_id: &'static str, @@ -75,6 +81,7 @@ impl McpManager { pub async fn runtime_config(&self, config: &Config) -> McpConfig { self.runtime_config_with_context(McpServerContributionContext::global(config)) .await + .config } pub(crate) async fn runtime_config_for_step( @@ -83,7 +90,7 @@ impl McpManager { thread_init: &ExtensionDataInit, thread_store: &ExtensionData, available_environment_ids: &[String], - ) -> McpConfig { + ) -> McpRuntimeProjection { self.runtime_config_with_context(McpServerContributionContext::for_step( config, thread_init, @@ -96,8 +103,9 @@ impl McpManager { async fn runtime_config_with_context( &self, context: McpServerContributionContext<'_, Config>, - ) -> McpConfig { + ) -> McpRuntimeProjection { let config = context.config(); + let mut selected_plugin_available = false; let mut selected_plugin_connector_sources = Vec::new(); let mut selected_plugin_registrations = Vec::new(); let mut overlays = Vec::new(); @@ -129,17 +137,22 @@ impl McpManager { *config, ), ), - McpServerContribution::SelectedPluginConnectors { + McpServerContribution::SelectedPluginPackage { plugin_id, plugin_display_name, connector_ids, - } => selected_plugin_connector_sources.push( - PluginConnectorSource::from_connector_ids( - plugin_id, - plugin_display_name, - connector_ids.into_iter().map(AppConnectorId), - ), - ), + } => { + selected_plugin_available = true; + if !connector_ids.is_empty() { + selected_plugin_connector_sources.push( + PluginConnectorSource::from_connector_ids( + plugin_id, + plugin_display_name, + connector_ids.into_iter().map(AppConnectorId), + ), + ); + } + } McpServerContribution::Remove { name } => { overlays.push(OrderedMcpOverlay::Remove { contributor_id: contributor.id(), @@ -152,12 +165,14 @@ impl McpManager { } } - let mut mcp_config = config - .to_mcp_config_with_plugin_registrations( - self.plugins_manager.as_ref(), - selected_plugin_registrations, - ) + let loaded_plugins = self + .plugins_manager + .plugins_for_config(&config.plugins_config_input()) .await; + let plugins_available = + selected_plugin_available || !loaded_plugins.capability_summaries().is_empty(); + let mut mcp_config = config + .to_mcp_config_with_loaded_plugins(&loaded_plugins, selected_plugin_registrations); let mut catalog = mcp_config.mcp_server_catalog.to_builder(); if mcp_config.apps_enabled { catalog.register(McpServerRegistration::from_compatibility( @@ -211,7 +226,10 @@ impl McpManager { .merged_with(&ConnectorSnapshot::from_plugin_sources( selected_plugin_connector_sources, )); - mcp_config + McpRuntimeProjection { + config: mcp_config, + plugins_available, + } } /// Returns config- and plugin-backed servers without runtime contributions. diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index d03f232d12..63d21504b9 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -1223,8 +1223,7 @@ async fn maybe_request_mcp_tool_approval( } let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref()); - let approval_required = requires_mcp_tool_approval(annotations); - if !approval_required && approval_mode != AppToolApproval::Prompt { + if !requires_mcp_tool_approval_for_mode(annotations, approval_mode) { return None; } @@ -1927,12 +1926,13 @@ fn normalize_approval_decision_for_mode( decision: McpToolApprovalDecision, approval_mode: AppToolApproval, ) -> McpToolApprovalDecision { - if approval_mode == AppToolApproval::Prompt - && matches!( - decision, - McpToolApprovalDecision::AcceptForSession | McpToolApprovalDecision::AcceptAndRemember - ) - { + if matches!( + approval_mode, + AppToolApproval::Prompt | AppToolApproval::Writes + ) && matches!( + decision, + McpToolApprovalDecision::AcceptForSession | McpToolApprovalDecision::AcceptAndRemember + ) { McpToolApprovalDecision::Accept } else { decision @@ -2179,6 +2179,20 @@ fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool { .unwrap_or(true) } +fn requires_mcp_tool_approval_for_mode( + annotations: Option<&ToolAnnotations>, + approval_mode: AppToolApproval, +) -> bool { + match approval_mode { + AppToolApproval::Auto => requires_mcp_tool_approval(annotations), + AppToolApproval::Prompt => true, + AppToolApproval::Writes => !annotations + .and_then(|annotations| annotations.read_only_hint) + .unwrap_or(false), + AppToolApproval::Approve => false, + } +} + async fn notify_mcp_tool_call_skip( sess: &Session, turn_context: &TurnContext, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 6b642b1cd0..277caa482d 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -398,23 +398,47 @@ fn approval_not_required_when_read_only_and_other_hints_are_absent() { } #[test] -fn prompt_mode_does_not_allow_persistent_remember() { +fn writes_mode_requires_approval_for_non_read_only_tools() { + let annotations = annotations(Some(false), Some(false), Some(false)); assert_eq!( - normalize_approval_decision_for_mode( - McpToolApprovalDecision::AcceptForSession, - AppToolApproval::Prompt, - ), - McpToolApprovalDecision::Accept + requires_mcp_tool_approval_for_mode(Some(&annotations), AppToolApproval::Writes), + true ); assert_eq!( - normalize_approval_decision_for_mode( - McpToolApprovalDecision::AcceptAndRemember, - AppToolApproval::Prompt, - ), - McpToolApprovalDecision::Accept + requires_mcp_tool_approval_for_mode(/*annotations*/ None, AppToolApproval::Writes), + true ); } +#[test] +fn writes_mode_does_not_require_approval_for_read_only_tools() { + let annotations = annotations(Some(true), Some(true), Some(true)); + assert_eq!( + requires_mcp_tool_approval_for_mode(Some(&annotations), AppToolApproval::Writes), + false + ); +} + +#[test] +fn prompting_modes_do_not_allow_persistent_remember() { + for approval_mode in [AppToolApproval::Prompt, AppToolApproval::Writes] { + assert_eq!( + normalize_approval_decision_for_mode( + McpToolApprovalDecision::AcceptForSession, + approval_mode, + ), + McpToolApprovalDecision::Accept + ); + assert_eq!( + normalize_approval_decision_for_mode( + McpToolApprovalDecision::AcceptAndRemember, + approval_mode, + ), + McpToolApprovalDecision::Accept + ); + } +} + #[tokio::test] async fn mcp_tool_call_span_records_expected_fields() { let buffer: &'static std::sync::Mutex> = @@ -1439,6 +1463,7 @@ async fn host_owned_codex_apps_manager( codex_mcp::ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, + /*elicitation_lifecycle*/ None, codex_mcp::ElicitationRequestRouter::default(), ) .await; @@ -1447,7 +1472,11 @@ async fn host_owned_codex_apps_manager( #[tokio::test] async fn codex_apps_auth_elicitation_feature_disabled_returns_original_result() { - let (session, turn_context, rx_event) = make_session_and_context_with_rx().await; + let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; + let mut features = Features::with_defaults(); + features.disable(Feature::AuthElicitation); + let mutable_turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); + Arc::make_mut(&mut mutable_turn_context.config).features = ManagedFeatures::from(features); let manager = host_owned_codex_apps_manager(&session, &turn_context).await; let result = codex_apps_auth_failure_result(); let metadata = codex_apps_auth_failure_metadata(); @@ -1560,16 +1589,10 @@ async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_resu } #[tokio::test] -async fn codex_apps_auth_elicitation_feature_enabled_requests_elicitation() { - let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; +async fn codex_apps_auth_elicitation_enabled_by_default_requests_elicitation() { + let (session, turn_context, rx_event) = make_session_and_context_with_rx().await; let manager = host_owned_codex_apps_manager(&session, &turn_context).await; *session.active_turn.lock().await = Some(ActiveTurn::default()); - let mut features = Features::with_defaults(); - features.enable(Feature::AuthElicitation); - { - let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); - Arc::make_mut(&mut turn_context.config).features = ManagedFeatures::from(features); - } let result = codex_apps_auth_failure_result(); let metadata = codex_apps_auth_failure_metadata(); diff --git a/codex-rs/core/src/network_policy_decision_tests.rs b/codex-rs/core/src/network_policy_decision_tests.rs index 0640041847..8888bbe9c2 100644 --- a/codex-rs/core/src/network_policy_decision_tests.rs +++ b/codex-rs/core/src/network_policy_decision_tests.rs @@ -161,6 +161,7 @@ fn denied_network_policy_message_requires_deny_decision() { method: Some("GET".to_string()), mode: None, protocol: "http".to_string(), + execution_id: None, decision: Some("ask".to_string()), source: Some("decider".to_string()), port: Some(80), @@ -178,6 +179,7 @@ fn denied_network_policy_message_for_denylist_block_is_explicit() { method: Some("GET".to_string()), mode: None, protocol: "http".to_string(), + execution_id: None, decision: Some("deny".to_string()), source: Some("baseline_policy".to_string()), port: Some(80), diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 0a4fe2d7c2..62ed44ad81 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -2,9 +2,8 @@ use crate::config::find_codex_home; use crate::config::is_builtin_permission_profile_name; use crate::config::reject_unknown_builtin_permission_profile; use crate::config::resolve_permission_profile; -use crate::exec_policy::ExecPolicyError; use crate::exec_policy::format_exec_policy_error_with_source; -use crate::exec_policy::load_exec_policy; +use crate::exec_policy::load_exec_policy_with_warning; use anyhow::Context; use anyhow::Result; use codex_config::CONFIG_TOML_FILE; @@ -64,13 +63,15 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec (policy, None), - Err(err @ ExecPolicyError::ParsePolicy { .. }) => { - (codex_execpolicy::Policy::empty(), Some(err)) - } - Err(err) => return Err(err.into()), - }; + let layer_mtimes = collect_layer_mtimes(&config_layer_stack); + let state = build_config_state_from_layers(&config_layer_stack).await?; + Ok((state, layer_mtimes)) +} + +async fn build_config_state_from_layers( + config_layer_stack: &ConfigLayerStack, +) -> Result { + let (exec_policy, warning) = load_exec_policy_with_warning(config_layer_stack).await?; if let Some(err) = warning.as_ref() { tracing::warn!( "failed to parse execpolicy while building network proxy state: {}", @@ -78,12 +79,10 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec Vec { diff --git a/codex-rs/core/src/network_proxy_loader_tests.rs b/codex-rs/core/src/network_proxy_loader_tests.rs index d6c8efc9ae..f7c7c2e452 100644 --- a/codex-rs/core/src/network_proxy_loader_tests.rs +++ b/codex-rs/core/src/network_proxy_loader_tests.rs @@ -5,6 +5,9 @@ use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; +use codex_config::RequirementSource; +use codex_config::RequirementsExecPolicy; +use codex_config::Sourced; use codex_config::permissions_toml::NetworkDomainPermissionToml; use codex_config::permissions_toml::NetworkDomainPermissionsToml; use codex_execpolicy::Decision; @@ -12,6 +15,8 @@ use codex_execpolicy::NetworkRuleProtocol; use codex_execpolicy::Policy; use pretty_assertions::assert_eq; use std::collections::BTreeMap; +use std::fs; +use tempfile::tempdir; #[test] fn higher_precedence_profile_network_overlays_domain_entries() { @@ -225,6 +230,51 @@ fn execpolicy_network_rules_overlay_network_lists() { ); } +#[tokio::test] +async fn malformed_custom_rules_preserve_managed_denied_domain() { + let temp_dir = tempdir().expect("create temp dir"); + let policy_dir = temp_dir.path().join("rules"); + fs::create_dir_all(&policy_dir).expect("create policy dir"); + fs::write(policy_dir.join("broken.rules"), "prefix_rule(").expect("write malformed policy"); + + let mut requirements_exec_policy = Policy::empty(); + requirements_exec_policy + .add_network_rule( + "blocked.example.com", + NetworkRuleProtocol::Https, + Decision::Forbidden, + /*justification*/ None, + ) + .expect("managed network rule should be valid"); + let requirements = ConfigRequirements { + exec_policy: Some(Sourced::new( + RequirementsExecPolicy::new(requirements_exec_policy), + RequirementSource::Unknown, + )), + ..ConfigRequirements::default() + }; + let dot_codex_folder = AbsolutePathBuf::from_absolute_path(temp_dir.path()) + .expect("dot codex folder should be absolute"); + let layers = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::Project { dot_codex_folder }, + toml::Value::Table(Default::default()), + )], + requirements, + ConfigRequirementsToml::default(), + ) + .expect("layer stack should be valid"); + + let state = build_config_state_from_layers(&layers) + .await + .expect("proxy state should tolerate malformed custom rules"); + + assert_eq!( + state.config.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); +} + #[test] fn apply_network_constraints_includes_allow_all_unix_sockets_flag() { let config: toml::Value = toml::from_str( diff --git a/codex-rs/core/src/plugins/render.rs b/codex-rs/core/src/plugins/render.rs index fc197dbbea..4e7575bd62 100644 --- a/codex-rs/core/src/plugins/render.rs +++ b/codex-rs/core/src/plugins/render.rs @@ -6,7 +6,7 @@ use crate::plugins::PluginCapabilitySummary; #[cfg(test)] pub(crate) fn render_plugins_section(plugins: &[PluginCapabilitySummary]) -> Option { - AvailablePluginsInstructions::from_plugins(plugins).map(|instructions| instructions.render()) + (!plugins.is_empty()).then(|| AvailablePluginsInstructions.render()) } pub(crate) fn render_explicit_plugin_instructions( diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 0c81133be1..3915c8190d 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -48,6 +48,7 @@ use codex_protocol::protocol::RealtimeConversationSdpEvent; use codex_protocol::protocol::RealtimeConversationStartedEvent; use codex_protocol::protocol::RealtimeHandoffRequested; use codex_protocol::protocol::RealtimeOutputModality; +use codex_protocol::protocol::RealtimeTranscriptEntry; use codex_protocol::protocol::RealtimeVoice; use codex_protocol::protocol::RealtimeVoicesList; use http::HeaderMap; @@ -59,6 +60,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use tokio::sync::Mutex; use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; use tracing::debug; use tracing::error; use tracing::info; @@ -80,6 +82,7 @@ const REALTIME_V2_STEER_ACKNOWLEDGEMENT: &str = "This was sent to steer the previous background agent task."; const REALTIME_ACTIVE_RESPONSE_ERROR_PREFIX: &str = "Conversation already has an active response in progress:"; +const REALTIME_SESSION_ENDED_HANDOFF_INSTRUCTION: &str = "The user just ended their realtime session. Here is the remaining handoff/transcript tail. You probably do not have to do anything; acknowledge the handoff unless the transcript itself asks for something."; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RealtimeConversationEnd { @@ -89,7 +92,7 @@ enum RealtimeConversationEnd { } enum RealtimeFanoutTaskStop { - Abort, + Await, Detach, } @@ -203,6 +206,9 @@ struct RealtimeInputTask { handoff_state: RealtimeHandoffState, session_kind: RealtimeSessionKind, event_parser: RealtimeEventParser, + flush_transcript_tail_on_session_end: bool, + transcript_tail_tx: Sender, + stop_token: CancellationToken, } struct RealtimeInputChannels { @@ -242,12 +248,14 @@ struct ConversationState { input_task: JoinHandle<()>, fanout_task: Option>, realtime_active: Arc, + stop_token: CancellationToken, } struct RealtimeStart { api_provider: ApiProvider, extra_headers: Option, client_managed_handoffs: bool, + flush_transcript_tail_on_session_end: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, codex_response_handoff_prefix: Option, @@ -260,6 +268,7 @@ struct RealtimeStart { struct RealtimeStartOutput { realtime_active: Arc, events_rx: Receiver, + transcript_tail_rx: Receiver, sdp: Option, } @@ -294,7 +303,7 @@ impl RealtimeConversationManager { guard.take() }; if let Some(state) = previous_state { - stop_conversation_state(state, RealtimeFanoutTaskStop::Abort).await; + stop_conversation_state(state, RealtimeFanoutTaskStop::Await).await; } self.start_inner(start).await @@ -305,6 +314,7 @@ impl RealtimeConversationManager { api_provider, extra_headers, client_managed_handoffs, + flush_transcript_tail_on_session_end, codex_responses_as_items, codex_response_item_prefix, codex_response_handoff_prefix, @@ -327,8 +337,10 @@ impl RealtimeConversationManager { async_channel::bounded::(HANDOFF_OUT_QUEUE_CAPACITY); let (events_tx, events_rx) = async_channel::bounded::(OUTPUT_EVENTS_QUEUE_CAPACITY); + let (transcript_tail_tx, transcript_tail_rx) = async_channel::bounded::(1); let realtime_active = Arc::new(AtomicBool::new(true)); + let stop_token = CancellationToken::new(); let handoff = RealtimeHandoffState::new( handoff_output_tx, client_managed_handoffs, @@ -364,6 +376,9 @@ impl RealtimeConversationManager { session_kind, event_parser, realtime_active: Arc::clone(&realtime_active), + flush_transcript_tail_on_session_end, + transcript_tail_tx, + stop_token: stop_token.clone(), }); (task, Some(call.sdp)) } else { @@ -385,6 +400,9 @@ impl RealtimeConversationManager { handoff_state: handoff.clone(), session_kind, event_parser, + flush_transcript_tail_on_session_end, + transcript_tail_tx, + stop_token: stop_token.clone(), }); (task, None) }; @@ -398,10 +416,12 @@ impl RealtimeConversationManager { input_task: task, fanout_task: None, realtime_active: Arc::clone(&realtime_active), + stop_token, }); Ok(RealtimeStartOutput { realtime_active, events_rx, + transcript_tail_rx, sdp, }) } @@ -652,7 +672,7 @@ impl RealtimeConversationManager { }; if let Some(state) = state { - stop_conversation_state(state, RealtimeFanoutTaskStop::Abort).await; + stop_conversation_state(state, RealtimeFanoutTaskStop::Await).await; } Ok(()) } @@ -663,13 +683,12 @@ async fn stop_conversation_state( fanout_task_stop: RealtimeFanoutTaskStop, ) { state.realtime_active.store(false, Ordering::Relaxed); - state.input_task.abort(); + state.stop_token.cancel(); let _ = state.input_task.await; if let Some(fanout_task) = state.fanout_task.take() { match fanout_task_stop { - RealtimeFanoutTaskStop::Abort => { - fanout_task.abort(); + RealtimeFanoutTaskStop::Await => { let _ = fanout_task.await; } RealtimeFanoutTaskStop::Detach => {} @@ -716,6 +735,7 @@ struct PreparedRealtimeConversationStart { api_provider: ApiProvider, extra_headers: Option, client_managed_handoffs: bool, + flush_transcript_tail_on_session_end: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, codex_response_handoff_prefix: Option, @@ -800,6 +820,7 @@ async fn prepare_realtime_start( api_provider, extra_headers, client_managed_handoffs: params.client_managed_handoffs, + flush_transcript_tail_on_session_end: params.flush_transcript_tail_on_session_end, codex_responses_as_items: params.codex_responses_as_items, codex_response_item_prefix: params.codex_response_item_prefix, codex_response_handoff_prefix: params.codex_response_handoff_prefix, @@ -966,6 +987,7 @@ async fn handle_start_inner( api_provider, extra_headers, client_managed_handoffs, + flush_transcript_tail_on_session_end, codex_responses_as_items, codex_response_item_prefix, codex_response_handoff_prefix, @@ -984,6 +1006,7 @@ async fn handle_start_inner( api_provider, extra_headers, client_managed_handoffs, + flush_transcript_tail_on_session_end, codex_responses_as_items, codex_response_item_prefix, codex_response_handoff_prefix, @@ -1008,6 +1031,7 @@ async fn handle_start_inner( let RealtimeStartOutput { realtime_active, events_rx, + transcript_tail_rx, sdp, } = start_output; if let Some(sdp) = sdp { @@ -1027,10 +1051,8 @@ async fn handle_start_inner( msg, }; let mut end = RealtimeConversationEnd::TransportClosed; + // Drain already-parsed events so a queued handoff is routed before the final tail. while let Ok(event) = events_rx.recv().await { - if !fanout_realtime_active.load(Ordering::Relaxed) { - break; - } match &event { RealtimeEvent::AudioOut(_) => {} _ => { @@ -1054,9 +1076,6 @@ async fn handle_start_inner( let sess_for_routed_text = Arc::clone(&sess_clone); sess_for_routed_text.route_realtime_text_input(text).await; } - if !fanout_realtime_active.load(Ordering::Relaxed) { - break; - } sess_clone .send_event_raw(ev(EventMsg::RealtimeConversationRealtime( RealtimeConversationRealtimeEvent { @@ -1065,6 +1084,9 @@ async fn handle_start_inner( ))) .await; } + if let Ok(text) = transcript_tail_rx.recv().await { + sess_clone.route_realtime_text_input(text).await; + } if fanout_realtime_active.swap(false, Ordering::Relaxed) { match end { RealtimeConversationEnd::TransportClosed => { @@ -1103,8 +1125,11 @@ pub(crate) async fn handle_audio( } fn realtime_transcript_delta_from_handoff(handoff: &RealtimeHandoffRequested) -> Option { - let active_transcript = handoff - .active_transcript + realtime_transcript_delta(&handoff.active_transcript) +} + +fn realtime_transcript_delta(active_transcript: &[RealtimeTranscriptEntry]) -> Option { + let active_transcript = active_transcript .iter() .map(|entry| format!("{role}: {text}", role = entry.role, text = entry.text)) .collect::>() @@ -1254,6 +1279,9 @@ struct RealtimeWebrtcSidebandInputTask { session_kind: RealtimeSessionKind, event_parser: RealtimeEventParser, realtime_active: Arc, + flush_transcript_tail_on_session_end: bool, + transcript_tail_tx: Sender, + stop_token: CancellationToken, } fn spawn_webrtc_sideband_input_task(input: RealtimeWebrtcSidebandInputTask) -> JoinHandle<()> { @@ -1268,6 +1296,9 @@ fn spawn_webrtc_sideband_input_task(input: RealtimeWebrtcSidebandInputTask) -> J session_kind, event_parser, realtime_active, + flush_transcript_tail_on_session_end, + transcript_tail_tx, + stop_token, } = input; tokio::spawn(async move { @@ -1275,15 +1306,15 @@ fn spawn_webrtc_sideband_input_task(input: RealtimeWebrtcSidebandInputTask) -> J return; } - let connection = match client - .connect_webrtc_sideband( + let connection = match tokio::select! { + connection = client.connect_webrtc_sideband( session_config, &call_id, sideband_headers, default_headers(), - ) - .await - { + ) => connection, + _ = stop_token.cancelled() => return, + } { Ok(connection) => connection, Err(err) => { if realtime_active.load(Ordering::Relaxed) { @@ -1311,6 +1342,9 @@ fn spawn_webrtc_sideband_input_task(input: RealtimeWebrtcSidebandInputTask) -> J handoff_state, session_kind, event_parser, + flush_transcript_tail_on_session_end, + transcript_tail_tx, + stop_token, }) .await; }) @@ -1327,6 +1361,9 @@ async fn run_realtime_input_task(input: RealtimeInputTask) { handoff_state, session_kind, event_parser, + flush_transcript_tail_on_session_end, + transcript_tail_tx, + stop_token, } = input; let mut output_audio_state: Option = None; @@ -1334,6 +1371,7 @@ async fn run_realtime_input_task(input: RealtimeInputTask) { loop { let result = tokio::select! { + _ = stop_token.cancelled() => break, // Text input that should be sent into realtime. text = text_rx.recv() => { handle_text_input( @@ -1378,6 +1416,18 @@ async fn run_realtime_input_task(input: RealtimeInputTask) { break; } } + + if flush_transcript_tail_on_session_end + && let Some(transcript_delta) = + realtime_transcript_delta(&events.take_transcript_tail().await) + { + let _ = transcript_tail_tx + .send(wrap_realtime_delegation_input( + REALTIME_SESSION_ENDED_HANDOFF_INSTRUCTION, + Some(&transcript_delta), + )) + .await; + } } async fn handle_text_input( diff --git a/codex-rs/core/src/responses_metadata.rs b/codex-rs/core/src/responses_metadata.rs index 1852c4bea6..c76d3078cd 100644 --- a/codex-rs/core/src/responses_metadata.rs +++ b/codex-rs/core/src/responses_metadata.rs @@ -93,6 +93,22 @@ impl CompactionTurnMetadata { strategy: CompactionStrategy::Memento, } } + + pub(crate) fn trigger(self) -> CompactionTrigger { + self.trigger + } + + pub(crate) fn reason(self) -> CompactionReason { + self.reason + } + + pub(crate) fn implementation(self) -> CompactionImplementation { + self.implementation + } + + pub(crate) fn phase(self) -> CompactionPhase { + self.phase + } } #[derive(Clone, Copy, Debug)] diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 9733a9f66a..924750bc34 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -589,8 +589,8 @@ async fn shutdown_session_runtime(sess: &Arc) { if let Some(startup_prewarm) = sess.take_session_startup_prewarm().await { startup_prewarm.abort().await; } - sess.abort_all_tasks(TurnAbortReason::Interrupted).await; let _ = sess.conversation.shutdown().await; + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; sess.services .unified_exec_manager .terminate_all_processes() diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 25d95e30ea..3f5e92eca7 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::mcp::McpRuntimeProjection; use codex_exec_server::ResolvedSelectedCapabilityRoot; use codex_mcp::ElicitationReviewRequest; use codex_mcp::ElicitationReviewer; @@ -92,6 +93,7 @@ impl Session { &available_environment_ids, ) .await + .config } pub(crate) async fn runtime_mcp_servers( @@ -123,7 +125,7 @@ impl Session { if current.available_environment_ids() == available_environment_ids { return current; } - let mcp_config = self + let mcp_projection = self .services .mcp_manager .runtime_config_for_step( @@ -133,6 +135,7 @@ impl Session { &available_environment_ids, ) .await; + let mcp_config = &mcp_projection.config; let changed_environment_is_used_by_mcp = mcp_config .mcp_server_catalog .configured_servers() @@ -156,6 +159,7 @@ impl Session { // replacing the live manager and restarting its processes. let runtime = Arc::new(McpRuntimeSnapshot::new( Arc::new(current.config().clone()), + mcp_projection.plugins_available, current.manager_arc(), current.runtime_context().clone(), available_environment_ids, @@ -165,7 +169,7 @@ impl Session { } self.refresh_mcp_servers_inner( turn_context, - mcp_config, + mcp_projection, environments, &available_environment_ids, Some(self.mcp_elicitation_reviewer()), @@ -191,6 +195,11 @@ impl Session { Arc::new(GuardianMcpElicitationReviewer::new(self)) } + pub(crate) fn mcp_elicitation_lifecycle(&self) -> codex_mcp::ElicitationLifecycle { + let elicitations = self.services.elicitations.clone(); + codex_mcp::ElicitationLifecycle::new(move || elicitations.register()) + } + #[expect( clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" @@ -218,6 +227,7 @@ impl Session { }; } + let _elicitation = self.services.elicitations.register(); let (tx_response, rx_response) = oneshot::channel(); let prev_entry = { let mut active = self.active_turn.lock().await; @@ -309,12 +319,16 @@ impl Session { async fn refresh_mcp_servers_inner( &self, turn_context: &TurnContext, - mcp_config: McpConfig, + mcp_projection: McpRuntimeProjection, environments: &TurnEnvironmentSnapshot, available_environment_ids: &[String], elicitation_reviewer: Option, ) -> Arc { let auth = self.services.auth_manager.auth().await; + let McpRuntimeProjection { + config: mcp_config, + plugins_available, + } = mcp_projection; let mcp_config = Arc::new(mcp_config); let tool_plugin_provenance = codex_mcp::tool_plugin_provenance(&mcp_config); let mcp_servers = effective_mcp_servers(&mcp_config, auth.as_ref()); @@ -369,6 +383,7 @@ impl Session { tool_plugin_provenance, auth.as_ref(), elicitation_reviewer, + Some(self.mcp_elicitation_lifecycle()), current_runtime.manager().elicitation_router(), ) .await; @@ -376,6 +391,7 @@ impl Session { .set_elicitations_auto_deny(current_runtime.manager().elicitations_auto_deny()); self.services.publish_mcp_runtime( mcp_config, + plugins_available, mcp_runtime_context, available_environment_ids.to_vec(), refreshed_manager, @@ -448,7 +464,7 @@ impl Session { .latest_mcp_runtime() .available_environment_ids() .to_vec(); - let mut mcp_config = self + let mut mcp_projection = self .services .mcp_manager .runtime_config_for_step( @@ -458,12 +474,13 @@ impl Session { &available_environment_ids, ) .await; - mcp_config.mcp_server_catalog = mcp_config + mcp_projection.config.mcp_server_catalog = mcp_projection + .config .mcp_server_catalog .with_materialized_servers(mcp_servers); self.refresh_mcp_servers_inner( turn_context, - mcp_config, + mcp_projection, &turn_context.environments, &available_environment_ids, elicitation_reviewer, @@ -515,7 +532,7 @@ impl Session { .latest_mcp_runtime() .available_environment_ids() .to_vec(); - let mcp_config = self + let mcp_projection = self .services .mcp_manager .runtime_config_for_step( @@ -527,7 +544,7 @@ impl Session { .await; self.refresh_mcp_servers_inner( turn_context, - mcp_config, + mcp_projection, &turn_context.environments, &available_environment_ids, elicitation_reviewer, diff --git a/codex-rs/core/src/session/mcp_runtime.rs b/codex-rs/core/src/session/mcp_runtime.rs index 34cf07cc35..5e7aeffcee 100644 --- a/codex-rs/core/src/session/mcp_runtime.rs +++ b/codex-rs/core/src/session/mcp_runtime.rs @@ -5,9 +5,10 @@ use codex_mcp::McpConfig; use codex_mcp::McpConnectionManager; use codex_mcp::McpRuntimeContext; -/// MCP config, exact environment bindings, and manager used by one model request. +/// MCP config, plugin availability, exact environment bindings, and manager for one request. pub struct McpRuntimeSnapshot { config: Arc, + plugins_available: bool, manager: Arc, runtime_context: McpRuntimeContext, available_environment_ids: Vec, @@ -16,12 +17,14 @@ pub struct McpRuntimeSnapshot { impl McpRuntimeSnapshot { pub(crate) fn new( config: Arc, + plugins_available: bool, manager: Arc, runtime_context: McpRuntimeContext, available_environment_ids: Vec, ) -> Self { Self { config, + plugins_available, manager, runtime_context, available_environment_ids, @@ -32,6 +35,10 @@ impl McpRuntimeSnapshot { self.config.as_ref() } + pub(crate) fn plugins_available(&self) -> bool { + self.plugins_available + } + pub fn manager(&self) -> &McpConnectionManager { self.manager.as_ref() } @@ -86,6 +93,7 @@ impl McpRuntimeSnapshot { ); Arc::new(Self::new( Arc::new(mcp_config), + /*plugins_available*/ false, Arc::new(manager), runtime_context, Vec::new(), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 978768ba00..af2412e5c4 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -21,10 +21,8 @@ use crate::build_available_skills; use crate::compact; use crate::config::ManagedFeatures; use crate::config::resolve_tool_suggest_config_from_layer_stack; -use crate::connectors; +use crate::context::ApprovalPromptContext; use crate::context::ApprovedCommandPrefixSaved; -use crate::context::AppsInstructions; -use crate::context::AvailablePluginsInstructions; use crate::context::AvailableSkillsInstructions; use crate::context::CollaborationModeInstructions; use crate::context::ContextualUserFragment; @@ -632,11 +630,14 @@ impl Codex { developer_instructions: None, }, }; - let service_tier = get_service_tier( - config.service_tier.clone(), - config.features.enabled(Feature::FastMode), + let fast_mode_enabled = config.features.enabled(Feature::FastMode); + let initial_service_tier_warning = unsupported_service_tier_warning( + config.service_tier.as_deref(), + fast_mode_enabled, &model_info, ); + let service_tier = + get_service_tier(config.service_tier.clone(), fast_mode_enabled, &model_info); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -709,6 +710,14 @@ impl Codex { error!("Failed to create session: {e:#}"); map_session_init_error(&e, &config.codex_home) })?; + if let Some(message) = initial_service_tier_warning { + session + .send_event_raw(Event { + id: INITIAL_SUBMIT_ID.to_owned(), + msg: EventMsg::Warning(WarningEvent { message }), + }) + .await; + } let thread_id = session.thread_id; // This task will run until Op::Shutdown is received. @@ -911,6 +920,22 @@ fn get_service_tier( }) } +fn unsupported_service_tier_warning( + configured_service_tier: Option<&str>, + fast_mode_enabled: bool, + model_info: &ModelInfo, +) -> Option { + let service_tier = configured_service_tier.filter(|service_tier| { + fast_mode_enabled + && *service_tier != SERVICE_TIER_DEFAULT_REQUEST_VALUE + && !model_info.supports_service_tier(service_tier) + })?; + Some(format!( + "Configured service tier `{service_tier}` is not advertised as supported for model `{}` and will be omitted from requests.", + model_info.slug + )) +} + fn session_permission_profile_state_from_config( config: &Config, ) -> CodexResult { @@ -1142,12 +1167,8 @@ impl Session { state.session_configuration.codex_home().clone() } - pub(crate) fn subscribe_out_of_band_elicitation_pause_state(&self) -> watch::Receiver { - self.out_of_band_elicitation_paused.subscribe() - } - - pub(crate) fn set_out_of_band_elicitation_pause_state(&self, paused: bool) { - self.out_of_band_elicitation_paused.send_replace(paused); + pub(crate) fn subscribe_elicitation_pause_state(&self) -> watch::Receiver { + self.services.elicitations.subscribe() } pub(crate) fn get_tx_event(&self) -> Sender { @@ -1774,6 +1795,9 @@ impl Session { let show_raw_agent_reasoning = self.show_raw_agent_reasoning(); for legacy in legacy_source.as_legacy_events(show_raw_agent_reasoning) { + self.services + .rollout_thread_trace + .record_tool_call_event(turn_context.sub_id.clone(), &legacy); let legacy_event = Event { id: turn_context.sub_id.clone(), msg: legacy, @@ -2834,6 +2858,7 @@ impl Session { /// This may refresh filesystem-derived state. Normal turns should call it only from /// `run_turn` and pass the result down; standalone request or history boundaries may capture /// their own step. + #[tracing::instrument(name = "step_context.capture", level = "info", skip_all)] pub(crate) async fn capture_step_context( self: &Arc, turn_context: Arc, @@ -3182,7 +3207,14 @@ impl Session { PermissionsInstructions::from_permission_profile( &turn_context.permission_profile, turn_context.approval_policy.value(), - turn_context.config.approvals_reviewer, + ApprovalPromptContext::new( + turn_context.config.approvals_reviewer, + turn_context + .model_info + .model_messages + .as_ref() + .and_then(|messages| messages.approvals.as_ref()), + ), self.services.exec_policy.current().as_ref(), #[allow(deprecated)] &turn_context.cwd, @@ -3239,19 +3271,6 @@ impl Session { .push(PersonalitySpecInstructions::new(personality_message).render()); } } - if turn_context.config.include_apps_instructions && turn_context.apps_enabled() { - let accessible_and_enabled_connectors = - connectors::list_accessible_and_enabled_connectors_from_manager( - mcp.manager(), - &turn_context.config, - ) - .await; - if let Some(apps_instructions) = - AppsInstructions::from_connectors(&accessible_and_enabled_connectors) - { - developer_sections.push(apps_instructions.render()); - } - } if turn_context.config.include_skill_instructions { let available_skills = build_available_skills( turn_context.turn_skills.snapshot.outcome(), @@ -3306,11 +3325,6 @@ impl Session { { contextual_user_sections.push(recommended_plugins.render()); } - if let Some(plugin_instructions) = - AvailablePluginsInstructions::from_plugins(loaded_plugins.capability_summaries()) - { - developer_sections.push(plugin_instructions.render()); - } let context_contributors = self.services.extensions.context_contributors().to_vec(); for contributor in &context_contributors { for fragment in contributor diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 5e7046b148..08abb46377 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -30,7 +30,6 @@ pub(crate) struct Session { pub(crate) installation_id: String, pub(super) tx_event: Sender, pub(super) agent_status: watch::Sender, - pub(super) out_of_band_elicitation_paused: watch::Sender, pub(super) state: Mutex, /// Serializes rebuild/apply cycles for the running proxy; each cycle /// rebuilds from the current SessionState while holding this lock. @@ -679,7 +678,7 @@ impl Session { let mcp_runtime_context_for_auth = mcp_runtime_context.clone(); let auth_and_mcp_fut = async move { let auth = auth_manager_clone.auth().await; - let mcp_config = mcp_manager_for_mcp + let mcp_projection = mcp_manager_for_mcp .runtime_config_for_step( &config_for_mcp, mcp_thread_init_for_startup, @@ -687,8 +686,9 @@ impl Session { /*available_environment_ids*/ &[], ) .await; - let mcp_servers = codex_mcp::effective_mcp_servers(&mcp_config, auth.as_ref()); - let tool_plugin_provenance = codex_mcp::tool_plugin_provenance(&mcp_config); + let mcp_config = &mcp_projection.config; + let mcp_servers = codex_mcp::effective_mcp_servers(mcp_config, auth.as_ref()); + let tool_plugin_provenance = codex_mcp::tool_plugin_provenance(mcp_config); let auth_statuses = compute_auth_statuses( mcp_servers.iter(), config_for_mcp.mcp_oauth_credentials_store_mode, @@ -699,7 +699,7 @@ impl Session { .await; ( auth, - mcp_config, + mcp_projection, mcp_servers, auth_statuses, tool_plugin_provenance, @@ -714,7 +714,7 @@ impl Session { let ( thread_persistence_result, state_db_ctx, - (auth, mcp_config, mcp_servers, auth_statuses, tool_plugin_provenance), + (auth, mcp_projection, mcp_servers, auth_statuses, tool_plugin_provenance), ) = tokio::join!(thread_persistence_fut, state_db_fut, auth_and_mcp_fut); let mut live_thread_init = @@ -1056,6 +1056,7 @@ impl Session { unified_exec_manager: UnifiedExecProcessManager::new( config.background_terminal_max_timeout, ), + elicitations: crate::elicitation::ElicitationService::new(), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client, @@ -1110,7 +1111,11 @@ impl Session { config.features.enabled(Feature::RuntimeMetrics), Self::build_model_client_beta_features_header(config.as_ref()), /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), + /*concurrent_reasoning_summaries_enabled*/ config + .features + .enabled(Feature::ConcurrentReasoningSummaries), attestation_provider, + config.http_client_factory(), ) .with_prompt_cache_key_override( crate::guardian::prompt_cache_key_override_for_review_session( @@ -1124,15 +1129,11 @@ impl Session { tool_search_handler_cache: Default::default(), turn_environments: Arc::clone(&turn_environments), }; - let (out_of_band_elicitation_paused, _out_of_band_elicitation_paused_rx) = - watch::channel(false); - let sess = Arc::new(Session { thread_id, installation_id, tx_event: tx_event.clone(), agent_status, - out_of_band_elicitation_paused, state: Mutex::new(state), managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1), features: config.features.clone(), @@ -1208,13 +1209,17 @@ impl Session { sess.services.mcp_manager.codex_apps_tools_cache(), codex_apps_tools_cache_key(auth), config.prefix_mcp_tool_names(), - mcp_config.client_elicitation_capability.clone(), + mcp_projection + .config + .client_elicitation_capability + .clone(), sess.services .supports_openai_form_elicitation .load(std::sync::atomic::Ordering::Relaxed), tool_plugin_provenance, auth, Some(sess.mcp_elicitation_reviewer()), + Some(sess.mcp_elicitation_lifecycle()), codex_mcp::ElicitationRequestRouter::default(), ) .instrument(info_span!( @@ -1224,7 +1229,8 @@ impl Session { .await; sess.services .install_mcp_connection_manager( - Arc::new(mcp_config), + Arc::new(mcp_projection.config), + mcp_projection.plugins_available, mcp_runtime_context, /*available_environment_ids*/ Vec::new(), mcp_connection_manager, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index ece9d446b5..ac8f9d049e 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -33,6 +33,8 @@ use codex_core_skills::HostSkillsSnapshot; use core_test_support::test_codex::local_selections; use codex_features::Feature; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; use codex_login::CodexAuth; use codex_login::auth::AgentIdentityAuthPolicy; use codex_model_provider_info::ModelProviderInfo; @@ -490,7 +492,9 @@ fn test_model_client_session() -> crate::client::ModelClientSession { /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), ) .new_session() } @@ -1039,6 +1043,22 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an ) -> futures::future::BoxFuture<'a, ReviewDecision> { Box::pin(async { ReviewDecision::Approved }) } + + fn approval_action( + &self, + _req: &(), + ctx: &crate::tools::sandboxing::ApprovalCtx<'_>, + ) -> std::io::Result { + Ok(crate::tools::sandboxing::ApprovalAction::Shell { + id: ctx.call_id.to_string(), + command: Vec::new(), + #[allow(deprecated)] + cwd: ctx.turn.cwd.clone(), + sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, + additional_permissions: None, + justification: None, + }) + } } impl crate::tools::sandboxing::Sandboxable for ProbeToolRuntime { @@ -5384,6 +5404,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { unified_exec_manager: UnifiedExecProcessManager::new( config.background_terminal_max_timeout, ), + elicitations: crate::elicitation::ElicitationService::new(), shell_zsh_path: None, main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client: AnalyticsEventsClient::new( @@ -5443,7 +5464,12 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), + /*concurrent_reasoning_summaries_enabled*/ + config + .features + .enabled(Feature::ConcurrentReasoningSummaries), /*attestation_provider*/ None, + config.http_client_factory(), ), code_mode_service: crate::tools::code_mode::CodeModeService::new(Arc::new( codex_code_mode::InProcessCodeModeSessionProvider, @@ -5494,7 +5520,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { installation_id: "11111111-1111-4111-8111-111111111111".to_string(), tx_event, agent_status: agent_status_tx, - out_of_band_elicitation_paused: watch::channel(false).0, state: Mutex::new(state), managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1), features: config.features.clone(), @@ -7510,6 +7535,7 @@ where unified_exec_manager: UnifiedExecProcessManager::new( config.background_terminal_max_timeout, ), + elicitations: crate::elicitation::ElicitationService::new(), shell_zsh_path: None, main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client: AnalyticsEventsClient::new( @@ -7569,7 +7595,12 @@ where config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), + /*concurrent_reasoning_summaries_enabled*/ + config + .features + .enabled(Feature::ConcurrentReasoningSummaries), /*attestation_provider*/ None, + config.http_client_factory(), ), code_mode_service: crate::tools::code_mode::CodeModeService::new(Arc::new( codex_code_mode::InProcessCodeModeSessionProvider, @@ -7620,7 +7651,6 @@ where installation_id: "11111111-1111-4111-8111-111111111111".to_string(), tx_event, agent_status: agent_status_tx, - out_of_band_elicitation_paused: watch::channel(false).0, state: Mutex::new(state), managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1), features: config.features.clone(), @@ -7729,6 +7759,91 @@ async fn refresh_mcp_servers_keeps_the_previous_runtime_alive() { ); } +#[tokio::test] +async fn plugin_availability_change_reuses_the_mcp_manager() { + struct ReadyPluginContributor; + + impl codex_extension_api::McpServerContributor for ReadyPluginContributor { + fn id(&self) -> &'static str { + "ready_plugin_test" + } + + fn contribute<'a>( + &'a self, + context: codex_extension_api::McpServerContributionContext<'a, Config>, + ) -> codex_extension_api::ExtensionFuture<'a, Vec> + { + Box::pin(async move { + let available = context + .available_environment_ids() + .is_some_and(|ids| ids.iter().any(|id| id == "executor")); + available + .then( + || codex_extension_api::McpServerContribution::SelectedPluginPackage { + plugin_id: "skill-only".to_string(), + plugin_display_name: "Skill Only".to_string(), + connector_ids: Vec::new(), + }, + ) + .into_iter() + .collect() + }) + } + } + + let (mut session, turn_context) = make_session_and_context().await; + let mut registry = codex_extension_api::ExtensionRegistryBuilder::new(); + registry.mcp_server_contributor(Arc::new(ReadyPluginContributor)); + let registry = Arc::new(registry.build()); + session.services.extensions = Arc::clone(®istry); + session.services.mcp_manager = Arc::new(McpManager::new_with_extensions( + Arc::clone(&session.services.plugins_manager), + registry, + )); + let session = Arc::new(session); + session + .refresh_mcp_servers_now( + &turn_context, + &turn_context.config, + /*elicitation_reviewer*/ None, + ) + .await; + let old_runtime = session.services.latest_mcp_runtime(); + let old_manager = old_runtime.manager_arc(); + + let selected_root = codex_protocol::capabilities::SelectedCapabilityRoot { + id: "skill-only".to_string(), + location: codex_protocol::capabilities::CapabilityRootLocation::Environment { + environment_id: "executor".to_string(), + path: PathUri::from_host_native_path(turn_context.config.cwd.as_path()) + .expect("selected capability root URI"), + }, + }; + let environment = Arc::clone( + &turn_context + .environments + .primary() + .expect("ready test environment") + .environment, + ); + let captured_environments = HashMap::from([("executor".to_string(), Some(environment))]); + let resolved_roots = session + .services + .turn_environments + .environment_manager() + .resolve_selected_capability_roots(&[selected_root], &captured_environments) + .await; + + let new_runtime = session + .mcp_runtime_for_step(&turn_context, &turn_context.environments, &resolved_roots) + .await; + + assert!(!old_runtime.plugins_available()); + assert!(new_runtime.plugins_available()); + assert!(!Arc::ptr_eq(&old_runtime, &new_runtime)); + assert!(Arc::ptr_eq(&old_manager, &new_runtime.manager_arc())); +} + #[tokio::test] async fn built_tools_uses_the_step_mcp_runtime() -> anyhow::Result<()> { let (session, turn_context) = make_session_and_context().await; @@ -8926,18 +9041,13 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co #[tokio::test] async fn record_context_updates_and_set_reference_context_item_persists_baseline_without_emitting_diffs() { - let (mut session, previous_context) = make_session_and_context().await; - let next_model = if previous_context.model_info.slug == "gpt-5.4" { - "gpt-5.2" - } else { - "gpt-5.4" - }; - let turn_context = previous_context - .with_model(next_model.to_string(), &session.services.models_manager) - .await; - let previous_context_item = previous_context.to_turn_context_item(); - let previous_context = Arc::new(previous_context); + let (mut session, turn_context) = make_session_and_context().await; + let previous_context_item = turn_context.to_turn_context_item(); + let previous_context = Arc::new(turn_context); let world_state = build_world_state_from_turn_context(&session, &previous_context).await; + let mut turn_context = Arc::try_unwrap(previous_context) + .unwrap_or_else(|_| panic!("previous turn context should have no remaining references")); + turn_context.sub_id = format!("{}-next", turn_context.sub_id); { let mut state = session.state.lock().await; state.set_reference_context_item(Some(previous_context_item.clone())); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index f16b525002..e231f6af86 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -349,6 +349,7 @@ pub(crate) async fn run_turn( if let Err(err) = run_auto_compact( &sess, Arc::clone(&step_context), + /*fallback_step_context*/ None, &mut client_session, InitialContextInjection::BeforeLastUserMessage(Arc::clone(&world_state)), CompactionReason::ContextLimit, @@ -810,6 +811,7 @@ async fn run_pre_sampling_compact( run_auto_compact( sess, step_context, + /*fallback_step_context*/ None, client_session, InitialContextInjection::DoNotInject, CompactionReason::ContextLimit, @@ -828,6 +830,28 @@ fn comp_hash_changed(previous: Option<&str>, current: Option<&str>) -> bool { .is_some_and(|(previous, current)| previous != current) } +/// Captures the current model's request-scoped state for retrying previous-model compaction. +/// +/// Returns `None` when the active authentication does not use the Codex backend, the provider is +/// not OpenAI, or the previous and current model are the same. +async fn capture_current_model_fallback_step_context( + sess: &Arc, + turn_context: &Arc, + previous_model: &str, +) -> Option> { + let uses_codex_backend = turn_context + .auth_manager + .as_deref() + .is_some_and(codex_login::AuthManager::current_auth_uses_codex_backend); + if !uses_codex_backend + || !turn_context.provider.info().is_openai() + || previous_model == turn_context.model_info.slug + { + return None; + } + Some(sess.capture_step_context(Arc::clone(turn_context)).await) +} + /// Runs pre-sampling compaction against the previous model when its compaction compatibility /// hash changed or when switching to a smaller context-window model. /// @@ -844,20 +868,27 @@ async fn maybe_run_previous_model_inline_compact( previous_turn_settings.comp_hash.as_deref(), turn_context.model_info.comp_hash.as_deref(), ); + let previous_model = previous_turn_settings.model; let previous_model_turn_context = Arc::new( turn_context - .with_model(previous_turn_settings.model, &sess.services.models_manager) + .with_model(previous_model.clone(), &sess.services.models_manager) .await, ); if should_compact_for_comp_hash_change { - // This pre-turn request needs a step built from the previous model's turn context. let step_context = sess .capture_step_context(Arc::clone(&previous_model_turn_context)) .await; + let fallback_step_context = capture_current_model_fallback_step_context( + sess, + turn_context, + previous_model.as_str(), + ) + .await; run_auto_compact( sess, step_context, + fallback_step_context, client_session, InitialContextInjection::DoNotInject, CompactionReason::CompHashChanged, @@ -892,13 +923,19 @@ async fn maybe_run_previous_model_inline_compact( && previous_model_turn_context.model_info.slug != turn_context.model_info.slug && old_context_window > new_context_window; if should_run { - // This pre-turn request needs a step built from the previous model's turn context. let step_context = sess .capture_step_context(Arc::clone(&previous_model_turn_context)) .await; + let fallback_step_context = capture_current_model_fallback_step_context( + sess, + turn_context, + previous_model.as_str(), + ) + .await; run_auto_compact( sess, step_context, + fallback_step_context, client_session, InitialContextInjection::DoNotInject, CompactionReason::ModelDownshift, @@ -917,6 +954,7 @@ async fn maybe_run_previous_model_inline_compact( async fn run_auto_compact( sess: &Arc, step_context: Arc, + fallback_step_context: Option>, client_session: &mut ModelClientSession, initial_context_injection: InitialContextInjection, reason: CompactionReason, @@ -949,6 +987,7 @@ async fn run_auto_compact( run_inline_remote_auto_compact_task_v2( Arc::clone(sess), step_context, + fallback_step_context, client_session, initial_context_injection, reason, @@ -965,6 +1004,7 @@ async fn run_auto_compact( run_inline_remote_auto_compact_task( Arc::clone(sess), step_context, + fallback_step_context, client_session.turn_state(), initial_context_injection, reason, @@ -1909,6 +1949,11 @@ async fn try_run_sampling_request( turn_context.provider.info().name.as_str(), ); let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling(); + let uses_sequential_cutoff_reasoning_summaries = turn_context + .config + .features + .enabled(Feature::ConcurrentReasoningSummaries) + && turn_context.provider.info().is_openai(); let mut stream = client_session .stream( prompt, @@ -2291,6 +2336,9 @@ async fn try_run_sampling_request( delta, summary_index, } => { + if uses_sequential_cutoff_reasoning_summaries { + continue; + } if let Some(active) = active_item.as_ref() { if !active_item_is_streaming_to_client { continue; @@ -2309,6 +2357,9 @@ async fn try_run_sampling_request( } } ResponseEvent::ReasoningSummaryPartAdded { summary_index } => { + if uses_sequential_cutoff_reasoning_summaries { + continue; + } if let Some(active) = active_item.as_ref() { if !active_item_is_streaming_to_client { continue; @@ -2323,6 +2374,40 @@ async fn try_run_sampling_request( error_or_panic("ReasoningSummaryPartAdded without active item".to_string()); } } + ResponseEvent::ReasoningSummaryDone { + item_id, + text, + summary_index, + } => { + if !uses_sequential_cutoff_reasoning_summaries { + continue; + } + let Some(active) = active_item.as_ref() else { + continue; + }; + if !active_item_is_streaming_to_client || active.id() != item_id { + continue; + } + if summary_index > 0 { + sess.send_event( + &turn_context, + EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent { + item_id: item_id.clone(), + summary_index, + }), + ) + .await; + } + let event = ReasoningContentDeltaEvent { + thread_id: sess.thread_id.to_string(), + turn_id: turn_context.sub_id.clone(), + item_id, + delta: text, + summary_index, + }; + sess.send_event(&turn_context, EventMsg::ReasoningContentDelta(event)) + .await; + } ResponseEvent::ReasoningContentDelta { delta, content_index, diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 10d6a5d0d1..a0d93cac83 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -1,11 +1,15 @@ use super::session::Session; use super::step_context::StepContext; +use crate::connectors; use crate::context::world_state::AgentsMdState; +use crate::context::world_state::AppsInstructionsState; use crate::context::world_state::EnvironmentsState; +use crate::context::world_state::PluginsInstructionsState; use crate::context::world_state::WorldState; use codex_extension_api::WorldStateContributionInput; impl Session { + #[tracing::instrument(name = "world_state.build", level = "info", skip_all)] pub(crate) async fn build_world_state_for_step( &self, step_context: &StepContext, @@ -35,6 +39,22 @@ impl Session { .with_subagents(environment_subagents), ); } + let apps_available = + if turn_context.config.include_apps_instructions && turn_context.apps_enabled() { + let tools = step_context.mcp.manager().list_all_tools().await; + connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&tools), + &turn_context.config, + ) + .into_iter() + .any(|connector| connector.is_accessible && connector.is_enabled) + } else { + false + }; + world_state.add_section(AppsInstructionsState::new(apps_available)); + world_state.add_section(PluginsInstructionsState::new( + step_context.mcp.plugins_available(), + )); let environments = step_context.environments.to_selections(); let ready_selected_capability_roots = step_context .selected_capability_roots diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index a611b94887..43f411e31b 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -10,6 +10,7 @@ use crate::client::ModelClient; use crate::config::NetworkProxyAuditMetadata; use crate::config::StartedNetworkProxy; use crate::current_time::TimeProvider; +use crate::elicitation::ElicitationService; use crate::environment_selection::ThreadEnvironments; use crate::exec_policy::ExecPolicyManager; use crate::guardian::GuardianRejection; @@ -55,6 +56,7 @@ pub(crate) struct SessionServices { pub(crate) mcp_projection_lock: Mutex<()>, pub(crate) mcp_startup_cancellation_token: Mutex, pub(crate) unified_exec_manager: UnifiedExecProcessManager, + pub(crate) elicitations: ElicitationService, #[cfg_attr(not(unix), allow(dead_code))] pub(crate) shell_zsh_path: Option, #[cfg_attr(not(unix), allow(dead_code))] @@ -107,18 +109,25 @@ impl SessionServices { pub(crate) async fn install_mcp_connection_manager( &self, config: Arc, + plugins_available: bool, runtime_context: McpRuntimeContext, available_environment_ids: Vec, manager: McpConnectionManager, ) -> Result<()> { - let runtime = - self.publish_mcp_runtime(config, runtime_context, available_environment_ids, manager); + let runtime = self.publish_mcp_runtime( + config, + plugins_available, + runtime_context, + available_environment_ids, + manager, + ); runtime.manager().validate_required_servers().await } pub(crate) fn publish_mcp_runtime( &self, config: Arc, + plugins_available: bool, runtime_context: McpRuntimeContext, available_environment_ids: Vec, manager: McpConnectionManager, @@ -129,6 +138,7 @@ impl SessionServices { self.mcp_connection_manager.store(Arc::clone(&manager)); let runtime = Arc::new(McpRuntimeSnapshot::new( config, + plugins_available, manager, runtime_context, available_environment_ids, diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index d588e5a356..a15c53e16b 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -25,16 +25,15 @@ use crate::tools::runtimes::RuntimePathPrepends; use crate::tools::runtimes::apply_package_path_prepend; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::runtimes::strip_managed_proxy_env; -use crate::turn_timing::now_unix_timestamp_ms; use crate::user_shell_command::user_shell_command_record_item; use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::exec_output::StreamOutput; +use codex_protocol::items::CommandExecutionItem; +use codex_protocol::items::CommandExecutionStatus; +use codex_protocol::items::TurnItem; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ExecCommandBeginEvent; -use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::ExecCommandSource; -use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::TurnStartedEvent; use codex_sandboxing::SandboxType; use codex_shell_command::parse_command::parse_command; @@ -181,18 +180,23 @@ pub(crate) async fn execute_user_shell_command( let parsed_cmd = parse_command(&display_command); session - .send_event( + .emit_turn_item_started( turn_context.as_ref(), - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: call_id.clone(), + &TurnItem::CommandExecution(CommandExecutionItem { + id: call_id.clone(), process_id: None, - turn_id: turn_context.sub_id.clone(), - started_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), cwd: cwd.clone().into(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, + status: CommandExecutionStatus::InProgress, + stdout: None, + stderr: None, + aggregated_output: None, + exit_code: None, + duration: None, + formatted_output: None, }), ) .await; @@ -259,57 +263,53 @@ pub(crate) async fn execute_user_shell_command( ) .await; session - .send_event( + .emit_turn_item_completed( turn_context.as_ref(), - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id, + TurnItem::CommandExecution(CommandExecutionItem { + id: call_id, process_id: None, - turn_id: turn_context.sub_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), cwd: cwd.clone().into(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, - stdout: String::new(), - stderr: aborted_message.clone(), - aggregated_output: aborted_message.clone(), - exit_code: -1, - duration: Duration::ZERO, - formatted_output: aborted_message, - status: ExecCommandStatus::Failed, + status: CommandExecutionStatus::Failed, + stdout: Some(String::new()), + stderr: Some(aborted_message.clone()), + aggregated_output: Some(aborted_message.clone()), + exit_code: Some(-1), + duration: Some(Duration::ZERO), + formatted_output: Some(aborted_message), }), ) .await; } Ok(Ok(output)) => { session - .send_event( + .emit_turn_item_completed( turn_context.as_ref(), - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: call_id.clone(), + TurnItem::CommandExecution(CommandExecutionItem { + id: call_id.clone(), process_id: None, - turn_id: turn_context.sub_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), cwd: cwd.clone().into(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, - stdout: output.stdout.text.clone(), - stderr: output.stderr.text.clone(), - aggregated_output: output.aggregated_output.text.clone(), - exit_code: output.exit_code, - duration: output.duration, - formatted_output: format_exec_output_str( + status: if output.exit_code == 0 { + CommandExecutionStatus::Completed + } else { + CommandExecutionStatus::Failed + }, + stdout: Some(output.stdout.text.clone()), + stderr: Some(output.stderr.text.clone()), + aggregated_output: Some(output.aggregated_output.text.clone()), + exit_code: Some(output.exit_code), + duration: Some(output.duration), + formatted_output: Some(format_exec_output_str( &output, turn_context.model_info.truncation_policy.into(), - ), - status: if output.exit_code == 0 { - ExecCommandStatus::Completed - } else { - ExecCommandStatus::Failed - }, + )), }), ) .await; @@ -329,28 +329,26 @@ pub(crate) async fn execute_user_shell_command( timed_out: false, }; session - .send_event( + .emit_turn_item_completed( turn_context.as_ref(), - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id, + TurnItem::CommandExecution(CommandExecutionItem { + id: call_id, process_id: None, - turn_id: turn_context.sub_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), command: display_command, cwd: cwd.into(), parsed_cmd, source: ExecCommandSource::UserShell, interaction_input: None, - stdout: exec_output.stdout.text.clone(), - stderr: exec_output.stderr.text.clone(), - aggregated_output: exec_output.aggregated_output.text.clone(), - exit_code: exec_output.exit_code, - duration: exec_output.duration, - formatted_output: format_exec_output_str( + status: CommandExecutionStatus::Failed, + stdout: Some(exec_output.stdout.text.clone()), + stderr: Some(exec_output.stderr.text.clone()), + aggregated_output: Some(exec_output.aggregated_output.text.clone()), + exit_code: Some(exec_output.exit_code), + duration: Some(exec_output.duration), + formatted_output: Some(format_exec_output_str( &exec_output, turn_context.model_info.truncation_policy.into(), - ), - status: ExecCommandStatus::Failed, + )), }), ) .await; diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 4f0da0c9c7..3c1d0146e7 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -699,11 +699,11 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() .collect::>() }; assert_eq!( - selected_servers(&first_resolved), + selected_servers(&first_resolved.config), std::collections::BTreeMap::from([("selected-a".to_string(), "env-a".to_string())]) ); assert_eq!( - selected_servers(&second_resolved), + selected_servers(&second_resolved.config), std::collections::BTreeMap::from([("selected-b".to_string(), "env-b".to_string())]) ); } diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs index 2e46c1bd6b..8374ae1ca4 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -85,6 +85,7 @@ impl CodeModeExecuteHandler { .code_mode_service .finish_cell_dispatch(&cell_id); } + exec.session.services.elicitations.wait_until_clear().await; handle_runtime_response(&exec, response, args.max_output_tokens, started_at) .await .map_err(FunctionCallError::RespondToModel) diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs index 953391a573..c3c867f3ef 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -120,6 +120,7 @@ impl CodeModeWaitHandler { .code_mode_service .finish_cell_dispatch(runtime_cell_id); } + exec.session.services.elicitations.wait_until_clear().await; handle_runtime_response(&exec, wait_response.into(), args.max_tokens, started_at) .await .map(boxed_tool_output) diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 757125a03d..5b30c6a156 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -8,6 +8,8 @@ use codex_apply_patch::AppliedPatchDelta; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; use codex_protocol::exec_output::ExecToolCallOutput; +use codex_protocol::items::CommandExecutionItem; +use codex_protocol::items::CommandExecutionStatus; use codex_protocol::items::FileChangeItem; use codex_protocol::items::TurnItem; use codex_protocol::parse_command::ParsedCommand; @@ -102,19 +104,43 @@ pub(crate) async fn emit_exec_command_begin( interaction_input: Option, process_id: Option<&str>, ) { + if matches!(source, ExecCommandSource::UnifiedExecInteraction) { + ctx.session + .send_event( + ctx.turn, + EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: ctx.call_id.to_string(), + process_id: process_id.map(str::to_owned), + turn_id: ctx.turn.sub_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + command: command.to_vec(), + cwd: cwd.clone(), + parsed_cmd: parsed_cmd.to_vec(), + source, + interaction_input, + }), + ) + .await; + return; + } ctx.session - .send_event( + .emit_turn_item_started( ctx.turn, - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: ctx.call_id.to_string(), + &TurnItem::CommandExecution(CommandExecutionItem { + id: ctx.call_id.to_string(), process_id: process_id.map(str::to_owned), - turn_id: ctx.turn.sub_id.clone(), - started_at_ms: now_unix_timestamp_ms(), command: command.to_vec(), cwd: cwd.clone(), parsed_cmd: parsed_cmd.to_vec(), source, interaction_input, + status: CommandExecutionStatus::InProgress, + stdout: None, + stderr: None, + aggregated_output: None, + exit_code: None, + duration: None, + formatted_output: None, }), ) .await; @@ -542,26 +568,50 @@ async fn emit_exec_end( exec_input: ExecCommandInput<'_>, exec_result: ExecCommandResult, ) { + if matches!(exec_input.source, ExecCommandSource::UnifiedExecInteraction) { + ctx.session + .send_event( + ctx.turn, + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: ctx.call_id.to_string(), + process_id: exec_input.process_id.map(str::to_owned), + turn_id: ctx.turn.sub_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), + command: exec_input.command.to_vec(), + cwd: exec_input.cwd.clone(), + parsed_cmd: exec_input.parsed_cmd.to_vec(), + source: exec_input.source, + interaction_input: exec_input.interaction_input.map(str::to_owned), + stdout: exec_result.stdout, + stderr: exec_result.stderr, + aggregated_output: exec_result.aggregated_output, + exit_code: exec_result.exit_code, + duration: exec_result.duration, + formatted_output: exec_result.formatted_output, + status: exec_result.status, + }), + ) + .await; + return; + } ctx.session - .send_event( + .emit_turn_item_completed( ctx.turn, - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: ctx.call_id.to_string(), + TurnItem::CommandExecution(CommandExecutionItem { + id: ctx.call_id.to_string(), process_id: exec_input.process_id.map(str::to_owned), - turn_id: ctx.turn.sub_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), command: exec_input.command.to_vec(), cwd: exec_input.cwd.clone(), parsed_cmd: exec_input.parsed_cmd.to_vec(), source: exec_input.source, interaction_input: exec_input.interaction_input.map(str::to_owned), - stdout: exec_result.stdout, - stderr: exec_result.stderr, - aggregated_output: exec_result.aggregated_output, - exit_code: exec_result.exit_code, - duration: exec_result.duration, - formatted_output: exec_result.formatted_output, - status: exec_result.status, + status: exec_result.status.into(), + stdout: Some(exec_result.stdout), + stderr: Some(exec_result.stderr), + aggregated_output: Some(exec_result.aggregated_output), + exit_code: Some(exec_result.exit_code), + duration: Some(exec_result.duration), + formatted_output: Some(exec_result.formatted_output), }), ) .await; diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index 28af3434ff..4b8fa03797 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -9,14 +9,13 @@ use crate::tools::handlers::parse_arguments; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolExposure; -use crate::turn_timing::now_unix_timestamp_ms; -use codex_protocol::dynamic_tools::DynamicToolCallRequest; use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec; use codex_protocol::dynamic_tools::DynamicToolResponse; +use codex_protocol::items::DynamicToolCallItem; +use codex_protocol::items::DynamicToolCallStatus; +use codex_protocol::items::TurnItem; use codex_protocol::models::FunctionCallOutputContentItem; -use codex_protocol::protocol::DynamicToolCallResponseEvent; -use codex_protocol::protocol::EventMsg; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolName; @@ -178,7 +177,6 @@ async fn request_dynamic_tool( ) -> Option { let namespace = tool_name.namespace; let tool = tool_name.name; - let turn_id = turn_context.sub_id.clone(); let (tx_response, rx_response) = oneshot::channel(); let event_id = call_id.clone(); let prev_entry = { @@ -196,45 +194,55 @@ async fn request_dynamic_tool( } let started_at = Instant::now(); - let started_at_ms = now_unix_timestamp_ms(); - let event = EventMsg::DynamicToolCallRequest(DynamicToolCallRequest { - call_id: call_id.clone(), - turn_id: turn_id.clone(), - started_at_ms, - namespace: namespace.clone(), - tool: tool.clone(), - arguments: arguments.clone(), - }); - session.send_event(turn_context, event).await; + session + .emit_turn_item_started( + turn_context, + &TurnItem::DynamicToolCall(DynamicToolCallItem { + id: call_id.clone(), + namespace: namespace.clone(), + tool: tool.clone(), + arguments: arguments.clone(), + status: DynamicToolCallStatus::InProgress, + content_items: None, + success: None, + error: None, + duration: None, + }), + ) + .await; let response = rx_response.await.ok(); - let response_event = match &response { - Some(response) => EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent { - call_id, - turn_id, - completed_at_ms: now_unix_timestamp_ms(), + let item = match &response { + Some(response) => DynamicToolCallItem { + id: call_id, namespace, tool, arguments, - content_items: response.content_items.clone(), - success: response.success, + status: if response.success { + DynamicToolCallStatus::Completed + } else { + DynamicToolCallStatus::Failed + }, + content_items: Some(response.content_items.clone()), + success: Some(response.success), error: None, - duration: started_at.elapsed(), - }), - None => EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent { - call_id, - turn_id, - completed_at_ms: now_unix_timestamp_ms(), + duration: Some(started_at.elapsed()), + }, + None => DynamicToolCallItem { + id: call_id, namespace, tool, arguments, - content_items: Vec::new(), - success: false, + status: DynamicToolCallStatus::Failed, + content_items: Some(Vec::new()), + success: Some(false), error: Some("dynamic tool call was cancelled before receiving a response".to_string()), - duration: started_at.elapsed(), - }), + duration: Some(started_at.elapsed()), + }, }; - session.send_event(turn_context, response_event).await; + session + .emit_turn_item_completed(turn_context, TurnItem::DynamicToolCall(item)) + .await; response } diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 1aa7754a18..716f4d3dd8 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -2,6 +2,8 @@ use std::sync::Arc; use std::sync::Weak; use codex_protocol::items::TurnItem; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; use codex_tools::ConversationHistory; use codex_tools::ExtensionTurnItem; use codex_tools::ToolCall as ExtensionToolCall; @@ -16,7 +18,6 @@ use crate::sandboxing::SandboxPermissions; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::stream_events_utils::TurnItemContributorPolicy; -use crate::stream_events_utils::apply_turn_item_contributors; use crate::stream_events_utils::finalize_turn_item; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -69,10 +70,14 @@ struct CoreTurnItemEmitter { turn: Weak, } -fn extension_turn_item(item: ExtensionTurnItem) -> TurnItem { - match item { - ExtensionTurnItem::WebSearch(item) => TurnItem::WebSearch(item), - ExtensionTurnItem::ImageGeneration(item) => TurnItem::ImageGeneration(item), +async fn emit_legacy_events(session: &Session, turn: &TurnContext, legacy_events: Vec) { + for msg in legacy_events { + session + .send_event_raw(Event { + id: turn.sub_id.clone(), + msg, + }) + .await; } } @@ -82,9 +87,15 @@ impl TurnItemEmitter for CoreTurnItemEmitter { let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { return; }; - session - .emit_turn_item_started(turn.as_ref(), &extension_turn_item(item)) - .await; + let (item, legacy_events) = match item { + ExtensionTurnItem::WebSearch(item) => (TurnItem::WebSearch(item), Vec::new()), + ExtensionTurnItem::Extension { + item, + legacy_events, + } => (TurnItem::Extension(item), legacy_events), + }; + session.emit_turn_item_started(turn.as_ref(), &item).await; + emit_legacy_events(session.as_ref(), turn.as_ref(), legacy_events).await; }) } @@ -93,17 +104,11 @@ impl TurnItemEmitter for CoreTurnItemEmitter { let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { return; }; - let item = match item { - ExtensionTurnItem::ImageGeneration(item) => { - let mut item = TurnItem::ImageGeneration(item); - apply_turn_item_contributors( - session.as_ref(), - turn.extension_data.as_ref(), - &mut item, - ) - .await; - item - } + let (item, legacy_events) = match item { + ExtensionTurnItem::Extension { + item, + legacy_events, + } => (TurnItem::Extension(item), legacy_events), ExtensionTurnItem::WebSearch(item) => { let mut item = TurnItem::WebSearch(item); finalize_turn_item( @@ -115,10 +120,11 @@ impl TurnItemEmitter for CoreTurnItemEmitter { == codex_protocol::config_types::ModeKind::Plan, ) .await; - item + (item, Vec::new()) } }; session.emit_turn_item_completed(turn.as_ref(), item).await; + emit_legacy_events(session.as_ref(), turn.as_ref(), legacy_events).await; }) } } @@ -173,14 +179,16 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { mod tests { use std::sync::Arc; - use codex_extension_api::ExtensionData; - use codex_extension_api::TurnItemContributor; + use codex_extension_items::ExtensionItem; + use codex_extension_items::image_generation::ImageGenerationItem; use codex_protocol::items::TurnItem; use codex_protocol::items::WebSearchItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::models::WebSearchAction; use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::ImageGenerationBeginEvent; + use codex_protocol::protocol::ImageGenerationEndEvent; use codex_tools::ExtensionTurnItem; use codex_utils_absolute_path::test_support::PathExt; use codex_utils_absolute_path::test_support::test_path_buf; @@ -447,147 +455,64 @@ mod tests { assert_eq!(end.action, expected.action); } - struct ImageGenerationExtensionExecutor; - - #[derive(Debug)] - struct ExtensionTurnItemContributorRan; - - struct RecordExtensionTurnItemContributor; - - impl TurnItemContributor for RecordExtensionTurnItemContributor { - fn contribute<'a>( - &'a self, - _thread_store: &'a ExtensionData, - turn_store: &'a ExtensionData, - _item: &'a mut TurnItem, - ) -> codex_extension_api::ExtensionFuture<'a, Result<(), String>> { - Box::pin(async move { - turn_store.insert(ExtensionTurnItemContributorRan); - Ok(()) - }) - } - } - - #[tokio::test] - async fn extension_completion_runs_turn_item_contributors() { - let (mut session, turn) = crate::session::tests::make_session_and_context().await; - let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); - builder.turn_item_contributor(Arc::new(RecordExtensionTurnItemContributor)); - session.services.extensions = Arc::new(builder.build()); - let session = Arc::new(session); - let turn = Arc::new(turn); - let emitter = CoreTurnItemEmitter { - session: Arc::downgrade(&session), - turn: Arc::downgrade(&turn), - }; - - codex_tools::TurnItemEmitter::emit_completed( - &emitter, - ExtensionTurnItem::WebSearch(WebSearchItem { - id: "search-1".to_string(), - query: "contributors".to_string(), - action: WebSearchAction::Other, - }), - ) - .await; - - assert!( - turn.extension_data - .get::() - .is_some() - ); - } - - impl codex_extension_api::ToolExecutor for ImageGenerationExtensionExecutor { - fn tool_name(&self) -> codex_tools::ToolName { - codex_tools::ToolName::namespaced("image_gen", "imagegen") - } - - fn spec(&self) -> codex_tools::ToolSpec { - codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool { - name: "imagegen".to_string(), - description: "Generates an image.".to_string(), - strict: false, - parameters: codex_tools::JsonSchema::default(), - output_schema: None, - defer_loading: None, - }) - } - - fn handle(&self, call: codex_tools::ToolCall) -> codex_tools::ToolExecutorFuture<'_> { - Box::pin(self.handle_call(call)) - } - } - - impl ImageGenerationExtensionExecutor { - async fn handle_call( - &self, - call: codex_tools::ToolCall, - ) -> Result, codex_tools::FunctionCallError> { - call.turn_item_emitter - .emit_started(ExtensionTurnItem::ImageGeneration( - codex_protocol::items::ImageGenerationItem { - id: call.call_id.clone(), - status: "in_progress".to_string(), - revised_prompt: None, - result: String::new(), - saved_path: None, - }, - )) - .await; - call.turn_item_emitter - .emit_completed(ExtensionTurnItem::ImageGeneration( - codex_protocol::items::ImageGenerationItem { - id: call.call_id, - status: "completed".to_string(), - revised_prompt: Some("A tiny blue square".to_string()), - result: "cG5n".to_string(), - saved_path: Some(test_path_buf("/tmp/extension-claimed.png").abs()), - }, - )) - .await; - Ok( - Box::new(codex_tools::JsonToolOutput::new(json!({ "ok": true }))) - as Box, - ) - } - } - #[tokio::test] async fn image_generation_publication_preserves_extension_saved_path() { let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await; - let handler = ExtensionToolAdapter::new(Arc::new(ImageGenerationExtensionExecutor)); let expected_path = test_path_buf("/tmp/extension-claimed.png").abs(); let default_path = crate::stream_events_utils::image_generation_artifact_path( &turn.config.codex_home, &session.thread_id.to_string(), "call-image", ); - let step_context = StepContext::for_test(Arc::clone(&turn)); - let invocation = ToolInvocation { - session, - step_context, - turn, - cancellation_token: tokio_util::sync::CancellationToken::new(), - tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), - call_id: "call-image".to_string(), - tool_name: codex_tools::ToolName::namespaced("image_gen", "imagegen"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: "{}".to_string(), - }, + let emitter = CoreTurnItemEmitter { + session: Arc::downgrade(&session), + turn: Arc::downgrade(&turn), }; - - crate::tools::registry::ToolExecutor::handle(&handler, invocation) - .await - .expect("extension call should succeed"); + let expected_started_item = ExtensionItem::ImageGeneration(ImageGenerationItem { + id: "call-image".to_string(), + status: "in_progress".to_string(), + revised_prompt: None, + result: String::new(), + saved_path: None, + }); + let expected_completed_item = ExtensionItem::ImageGeneration(ImageGenerationItem { + id: "call-image".to_string(), + status: "completed".to_string(), + revised_prompt: Some("A tiny blue square".to_string()), + result: "cG5n".to_string(), + saved_path: Some(expected_path.clone()), + }); + codex_tools::TurnItemEmitter::emit_started( + &emitter, + ExtensionTurnItem::Extension { + item: expected_started_item.clone(), + legacy_events: vec![EventMsg::ImageGenerationBegin(ImageGenerationBeginEvent { + call_id: "call-image".to_string(), + })], + }, + ) + .await; + codex_tools::TurnItemEmitter::emit_completed( + &emitter, + ExtensionTurnItem::Extension { + item: expected_completed_item.clone(), + legacy_events: vec![EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { + call_id: "call-image".to_string(), + status: "completed".to_string(), + revised_prompt: Some("A tiny blue square".to_string()), + result: "cG5n".to_string(), + saved_path: Some(expected_path.clone()), + })], + }, + ) + .await; let started = rx.recv().await.expect("item started event"); let EventMsg::ItemStarted(started) = started.msg else { panic!("expected item started event"); }; - let TurnItem::ImageGeneration(started_item) = started.item else { - panic!("expected image generation item"); + let TurnItem::Extension(started_item) = started.item else { + panic!("expected extension item"); }; let begin = rx.recv().await.expect("legacy image start event"); assert!(matches!(begin.msg, EventMsg::ImageGenerationBegin(_))); @@ -595,32 +520,14 @@ mod tests { let EventMsg::ItemCompleted(completed) = completed.msg else { panic!("expected item completed event"); }; - let TurnItem::ImageGeneration(completed_item) = completed.item else { - panic!("expected image generation item"); + let TurnItem::Extension(completed_item) = completed.item else { + panic!("expected extension item"); }; let end = rx.recv().await.expect("legacy image end event"); assert!(matches!(end.msg, EventMsg::ImageGenerationEnd(_))); - assert_eq!( - started_item, - codex_protocol::items::ImageGenerationItem { - id: "call-image".to_string(), - status: "in_progress".to_string(), - revised_prompt: None, - result: String::new(), - saved_path: None, - } - ); - assert_eq!( - completed_item, - codex_protocol::items::ImageGenerationItem { - id: "call-image".to_string(), - status: "completed".to_string(), - revised_prompt: Some("A tiny blue square".to_string()), - result: "cG5n".to_string(), - saved_path: Some(expected_path.clone()), - } - ); + assert_eq!(started_item, expected_started_item); + assert_eq!(completed_item, expected_completed_item); assert!(!default_path.exists()); } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 8bc96d19c2..87afc2b7cf 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -8,8 +8,6 @@ use crate::agent::AgentStatus; use crate::agent::exceeds_thread_spawn_depth_limit; use crate::function_tool::FunctionCallError; -use crate::session::session::Session; -use crate::session::turn_context::TurnContext; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -20,19 +18,13 @@ use crate::tools::handlers::parse_arguments; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; use codex_protocol::ThreadId; +use codex_protocol::items::CollabAgentTool; +use codex_protocol::items::CollabAgentToolCallItem; +use codex_protocol::items::CollabAgentToolCallStatus; +use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::protocol::CollabAgentInteractionBeginEvent; -use codex_protocol::protocol::CollabAgentInteractionEndEvent; use codex_protocol::protocol::CollabAgentRef; -use codex_protocol::protocol::CollabAgentSpawnBeginEvent; -use codex_protocol::protocol::CollabAgentSpawnEndEvent; -use codex_protocol::protocol::CollabCloseBeginEvent; -use codex_protocol::protocol::CollabCloseEndEvent; -use codex_protocol::protocol::CollabResumeBeginEvent; -use codex_protocol::protocol::CollabResumeEndEvent; -use codex_protocol::protocol::CollabWaitingBeginEvent; -use codex_protocol::protocol::CollabWaitingEndEvent; use codex_protocol::user_input::UserInput; use codex_tools::ToolName; use codex_tools::ToolSearchInfo; @@ -91,6 +83,17 @@ mod send_input; mod spawn; pub(crate) mod wait; +pub(crate) fn collab_tool_call_status( + status: &AgentStatus, + receiver_thread_id: Option, +) -> CollabAgentToolCallStatus { + match status { + AgentStatus::Errored(_) | AgentStatus::NotFound => CollabAgentToolCallStatus::Failed, + _ if receiver_thread_id.is_some() => CollabAgentToolCallStatus::Completed, + _ => CollabAgentToolCallStatus::Failed, + } +} + #[cfg(test)] #[path = "multi_agents_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs index d1017adb65..d905cf4b94 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -1,6 +1,5 @@ use super::*; use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v1; -use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::error::CodexErr; use codex_tools::ToolSpec; @@ -44,15 +43,20 @@ async fn handle_close_agent( let known_agent = receiver_agent.is_some(); let receiver_agent = receiver_agent.unwrap_or_default(); session - .send_event( + .emit_turn_item_started( &turn, - CollabCloseBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), + &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id.clone(), + tool: CollabAgentTool::CloseAgent, + status: CollabAgentToolCallStatus::InProgress, sender_thread_id: session.thread_id, - receiver_thread_id: agent_id, - } - .into(), + receiver_thread_ids: vec![agent_id], + receiver_agents: Vec::new(), + prompt: None, + model: None, + reasoning_effort: None, + agents_states: Default::default(), + }), ) .await; let status = match session @@ -68,18 +72,24 @@ async fn handle_close_agent( Err(err) => { let status = session.services.agent_control.get_status(agent_id).await; session - .send_event( + .emit_turn_item_completed( &turn, - CollabCloseEndEvent { - call_id: call_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), + TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id.clone(), + tool: CollabAgentTool::CloseAgent, + status: collab_tool_call_status(&status, Some(agent_id)), sender_thread_id: session.thread_id(), - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname.clone(), - receiver_agent_role: receiver_agent.agent_role.clone(), - status, - } - .into(), + receiver_thread_ids: vec![agent_id], + receiver_agents: vec![CollabAgentRef { + thread_id: agent_id, + agent_nickname: receiver_agent.agent_nickname.clone(), + agent_role: receiver_agent.agent_role.clone(), + }], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: [(agent_id, status)].into_iter().collect(), + }), ) .await; return Err(collab_agent_error(agent_id, err)); @@ -90,18 +100,24 @@ async fn handle_close_agent( .map_err(|err| collab_agent_error(agent_id, err)) .map(|_| ()); session - .send_event( + .emit_turn_item_completed( &turn, - CollabCloseEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), + TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id, + tool: CollabAgentTool::CloseAgent, + status: collab_tool_call_status(&status, Some(agent_id)), sender_thread_id: session.thread_id, - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - status: status.clone(), - } - .into(), + receiver_thread_ids: vec![agent_id], + receiver_agents: vec![CollabAgentRef { + thread_id: agent_id, + agent_nickname: receiver_agent.agent_nickname, + agent_role: receiver_agent.agent_role, + }], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: [(agent_id, status.clone())].into_iter().collect(), + }), ) .await; result?; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index fa464ddbbb..8e97b2afbd 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -1,7 +1,8 @@ use super::*; use crate::agent::next_thread_spawn_depth; +use crate::session::session::Session; +use crate::session::turn_context::TurnContext; use crate::tools::handlers::multi_agents_spec::create_resume_agent_tool; -use crate::turn_timing::now_unix_timestamp_ms; use codex_tools::ToolSpec; use std::sync::Arc; @@ -57,17 +58,24 @@ async fn handle_resume_agent( } session - .send_event( + .emit_turn_item_started( &turn, - CollabResumeBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), + &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id.clone(), + tool: CollabAgentTool::ResumeAgent, + status: CollabAgentToolCallStatus::InProgress, sender_thread_id: session.thread_id, - receiver_thread_id, - receiver_agent_nickname: receiver_agent.agent_nickname.clone(), - receiver_agent_role: receiver_agent.agent_role.clone(), - } - .into(), + receiver_thread_ids: vec![receiver_thread_id], + receiver_agents: vec![CollabAgentRef { + thread_id: receiver_thread_id, + agent_nickname: receiver_agent.agent_nickname.clone(), + agent_role: receiver_agent.agent_role.clone(), + }], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: Default::default(), + }), ) .await; @@ -113,18 +121,24 @@ async fn handle_resume_agent( (receiver_agent, None) }; session - .send_event( + .emit_turn_item_completed( &turn, - CollabResumeEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), + TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id, + tool: CollabAgentTool::ResumeAgent, + status: collab_tool_call_status(&status, Some(receiver_thread_id)), sender_thread_id: session.thread_id(), - receiver_thread_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - status: status.clone(), - } - .into(), + receiver_thread_ids: vec![receiver_thread_id], + receiver_agents: vec![CollabAgentRef { + thread_id: receiver_thread_id, + agent_nickname: receiver_agent.agent_nickname, + agent_role: receiver_agent.agent_role, + }], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: [(receiver_thread_id, status.clone())].into_iter().collect(), + }), ) .await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index 375bb50ac7..649eaf1ce0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -1,7 +1,6 @@ use super::*; use crate::agent::control::render_input_preview; use crate::tools::handlers::multi_agents_spec::create_send_input_tool_v1; -use crate::turn_timing::now_unix_timestamp_ms; use codex_tools::ToolSpec; pub(crate) struct Handler; @@ -67,16 +66,20 @@ impl Handler { .map_err(|err| collab_agent_error(receiver_thread_id, err))?; } session - .send_event( + .emit_turn_item_started( &turn, - CollabAgentInteractionBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), + &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id.clone(), + tool: CollabAgentTool::SendInput, + status: CollabAgentToolCallStatus::InProgress, sender_thread_id: session.thread_id, - receiver_thread_id, - prompt: prompt.clone(), - } - .into(), + receiver_thread_ids: vec![receiver_thread_id], + receiver_agents: Vec::new(), + prompt: Some(prompt.clone()), + model: None, + reasoning_effort: None, + agents_states: Default::default(), + }), ) .await; let agent_control = session.services.agent_control.clone(); @@ -90,19 +93,24 @@ impl Handler { .get_status(receiver_thread_id) .await; session - .send_event( + .emit_turn_item_completed( &turn, - CollabAgentInteractionEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), + TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id, + tool: CollabAgentTool::SendInput, + status: collab_tool_call_status(&status, Some(receiver_thread_id)), sender_thread_id: session.thread_id, - receiver_thread_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - prompt, - status, - } - .into(), + receiver_thread_ids: vec![receiver_thread_id], + receiver_agents: vec![CollabAgentRef { + thread_id: receiver_thread_id, + agent_nickname: receiver_agent.agent_nickname, + agent_role: receiver_agent.agent_role, + }], + prompt: Some(prompt), + model: None, + reasoning_effort: None, + agents_states: [(receiver_thread_id, status)].into_iter().collect(), + }), ) .await; let submission_id = result?; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 204385934e..e5923bab79 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -8,7 +8,6 @@ use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::apply_role_to_config; use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v1; -use crate::turn_timing::now_unix_timestamp_ms; use codex_tools::ToolSpec; #[derive(Default)] @@ -71,17 +70,20 @@ async fn handle_spawn_agent( )); } session - .send_event( + .emit_turn_item_started( &turn, - CollabAgentSpawnBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), + &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id.clone(), + tool: CollabAgentTool::SpawnAgent, + status: CollabAgentToolCallStatus::InProgress, sender_thread_id: session.thread_id, - prompt: prompt.clone(), - model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.clone().unwrap_or_default(), - } - .into(), + receiver_thread_ids: Vec::new(), + receiver_agents: Vec::new(), + prompt: Some(prompt.clone()), + model: Some(args.model.clone().unwrap_or_default()), + reasoning_effort: Some(args.reasoning_effort.clone().unwrap_or_default()), + agents_states: Default::default(), + }), ) .await; let mut config = @@ -177,22 +179,33 @@ async fn handle_spawn_agent( .and_then(|snapshot| snapshot.reasoning_effort.clone()) .unwrap_or(args.reasoning_effort.unwrap_or_default()); let nickname = new_agent_nickname.clone(); + let receiver_thread_ids = new_thread_id.into_iter().collect(); + let receiver_agents = new_thread_id + .map(|thread_id| CollabAgentRef { + thread_id, + agent_nickname: new_agent_nickname, + agent_role: new_agent_role, + }) + .into_iter() + .collect(); + let agents_states = new_thread_id + .map(|thread_id| [(thread_id, status.clone())].into_iter().collect()) + .unwrap_or_default(); session - .send_event( + .emit_turn_item_completed( &turn, - CollabAgentSpawnEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), + TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id, + tool: CollabAgentTool::SpawnAgent, + status: collab_tool_call_status(&status, new_thread_id), sender_thread_id: session.thread_id, - new_thread_id, - new_agent_nickname, - new_agent_role, - prompt, - model: effective_model, - reasoning_effort: effective_reasoning_effort, - status, - } - .into(), + receiver_thread_ids, + receiver_agents, + prompt: Some(prompt), + model: Some(effective_model), + reasoning_effort: Some(effective_reasoning_effort), + agents_states, + }), ) .await; let new_thread_id = result?.thread_id; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs index be22b36908..75fe4f0f9f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -1,8 +1,8 @@ use super::*; use crate::agent::status::is_final; +use crate::session::session::Session; use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v1; -use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::error::CodexErr; use codex_tools::ToolSpec; use futures::FutureExt; @@ -97,16 +97,20 @@ impl Handler { }; session - .send_event( + .emit_turn_item_started( &turn, - CollabWaitingBeginEvent { - started_at_ms: now_unix_timestamp_ms(), + &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id.clone(), + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::InProgress, sender_thread_id: session.thread_id, receiver_thread_ids: receiver_thread_ids.clone(), receiver_agents: receiver_agents.clone(), - call_id: call_id.clone(), - } - .into(), + prompt: None, + model: None, + reasoning_effort: None, + agents_states: Default::default(), + }), ) .await; @@ -128,19 +132,20 @@ impl Handler { let mut statuses = HashMap::with_capacity(1); statuses.insert(*id, session.services.agent_control.get_status(*id).await); session - .send_event( + .emit_turn_item_completed( &turn, - CollabWaitingEndEvent { + TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id.clone(), + tool: CollabAgentTool::Wait, + status: wait_tool_call_status(&statuses), sender_thread_id: session.thread_id, - call_id: call_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), - agent_statuses: build_wait_agent_statuses( - &statuses, - &receiver_agents, - ), - statuses, - } - .into(), + receiver_thread_ids: statuses.keys().copied().collect(), + receiver_agents: wait_receiver_agents(&statuses, &receiver_agents), + prompt: None, + model: None, + reasoning_effort: None, + agents_states: statuses, + }), ) .await; return Err(collab_agent_error(*id, err)); @@ -182,7 +187,6 @@ impl Handler { let timed_out = statuses.is_empty(); let statuses_by_id = statuses.clone().into_iter().collect::>(); - let agent_statuses = build_wait_agent_statuses(&statuses_by_id, &receiver_agents); let result = WaitAgentResult { status: statuses .into_iter() @@ -197,16 +201,20 @@ impl Handler { }; session - .send_event( + .emit_turn_item_completed( &turn, - CollabWaitingEndEvent { + TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id, + tool: CollabAgentTool::Wait, + status: wait_tool_call_status(&statuses_by_id), sender_thread_id: session.thread_id, - call_id, - completed_at_ms: now_unix_timestamp_ms(), - agent_statuses, - statuses: statuses_by_id, - } - .into(), + receiver_thread_ids: statuses_by_id.keys().copied().collect(), + receiver_agents: wait_receiver_agents(&statuses_by_id, &receiver_agents), + prompt: None, + model: None, + reasoning_effort: None, + agents_states: statuses_by_id, + }), ) .await; @@ -214,6 +222,48 @@ impl Handler { } } +fn wait_tool_call_status(statuses: &HashMap) -> CollabAgentToolCallStatus { + if statuses + .values() + .any(|status| matches!(status, AgentStatus::Errored(_) | AgentStatus::NotFound)) + { + CollabAgentToolCallStatus::Failed + } else { + CollabAgentToolCallStatus::Completed + } +} + +fn wait_receiver_agents( + statuses: &HashMap, + receiver_agents: &[CollabAgentRef], +) -> Vec { + if statuses.is_empty() { + return Vec::new(); + } + + let mut agents = Vec::with_capacity(statuses.len()); + let mut seen = HashMap::with_capacity(receiver_agents.len()); + for receiver_agent in receiver_agents { + seen.insert(receiver_agent.thread_id, ()); + if statuses.contains_key(&receiver_agent.thread_id) { + agents.push(receiver_agent.clone()); + } + } + + let mut extras = statuses + .keys() + .filter(|thread_id| !seen.contains_key(thread_id)) + .map(|thread_id| CollabAgentRef { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + }) + .collect::>(); + extras.sort_by_key(|agent| agent.thread_id.to_string()); + agents.extend(extras); + agents +} + impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 12ec89d651..287e4b30d5 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -1,4 +1,3 @@ -use crate::agent::AgentStatus; use crate::config::Config; use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; use crate::config::HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS; @@ -16,14 +15,11 @@ use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; -use codex_protocol::protocol::CollabAgentRef; -use codex_protocol::protocol::CollabAgentStatusEntry; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; use serde::Serialize; use serde_json::Value as JsonValue; -use std::collections::HashMap; /// Minimum wait timeout to prevent tight polling loops from burning CPU. pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; @@ -71,43 +67,6 @@ where }) } -pub(crate) fn build_wait_agent_statuses( - statuses: &HashMap, - receiver_agents: &[CollabAgentRef], -) -> Vec { - if statuses.is_empty() { - return Vec::new(); - } - - let mut entries = Vec::with_capacity(statuses.len()); - let mut seen = HashMap::with_capacity(receiver_agents.len()); - for receiver_agent in receiver_agents { - seen.insert(receiver_agent.thread_id, ()); - if let Some(status) = statuses.get(&receiver_agent.thread_id) { - entries.push(CollabAgentStatusEntry { - thread_id: receiver_agent.thread_id, - agent_nickname: receiver_agent.agent_nickname.clone(), - agent_role: receiver_agent.agent_role.clone(), - status: status.clone(), - }); - } - } - - let mut extras = statuses - .iter() - .filter(|(thread_id, _)| !seen.contains_key(thread_id)) - .map(|(thread_id, status)| CollabAgentStatusEntry { - thread_id: *thread_id, - agent_nickname: None, - agent_role: None, - status: status.clone(), - }) - .collect::>(); - extras.sort_by_key(|entry| entry.thread_id.to_string()); - entries.extend(extras); - entries -} - pub(crate) fn collab_spawn_error(err: CodexErr) -> FunctionCallError { match err { CodexErr::UnsupportedOperation(message) if message == "thread manager dropped" => { diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index f313fcf0d9..c369fccbc7 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -7,6 +7,7 @@ use crate::init_state_db; use crate::local_agent_graph_store_from_state_db; use crate::session::step_context::StepContext; use crate::session::tests::make_session_and_context; +use crate::session::turn_context::TurnContext; use crate::session_prefix::format_inter_agent_completion_message; use crate::thread_manager::thread_store_from_config; use crate::tools::context::ToolOutput; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index 8e7f73a762..d1446ec621 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -12,12 +12,14 @@ use crate::tools::handlers::parse_arguments; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; use codex_protocol::AgentPath; +use codex_protocol::items::CollabAgentTool; +use codex_protocol::items::CollabAgentToolCallItem; +use codex_protocol::items::CollabAgentToolCallStatus; +use codex_protocol::items::SubAgentActivityItem; +use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::protocol::CollabWaitingBeginEvent; -use codex_protocol::protocol::CollabWaitingEndEvent; use codex_protocol::protocol::InterAgentCommunication; -use codex_protocol::protocol::SubAgentActivityEvent; use codex_protocol::protocol::SubAgentActivityKind; use codex_tools::ToolName; use serde::Deserialize; @@ -39,6 +41,16 @@ mod send_message; mod spawn; pub(crate) mod wait; +pub(crate) async fn emit_sub_agent_activity( + session: &crate::session::session::Session, + turn: &crate::session::turn_context::TurnContext, + item: SubAgentActivityItem, +) { + session + .emit_turn_item_completed(turn, TurnItem::SubAgentActivity(item)) + .await; +} + pub(super) fn communication_from_tool_message( author: AgentPath, recipient: AgentPath, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs index aeed055b4e..00eb149ca1 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs @@ -1,6 +1,5 @@ use super::*; use crate::tools::handlers::multi_agents_spec::create_interrupt_agent_tool_v2; -use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::error::CodexErr; use codex_tools::ToolSpec; @@ -71,19 +70,17 @@ async fn handle_interrupt_agent( Err(err) => Err(collab_agent_error(agent_id, err)), }; result?; - session - .send_event( - &turn, - SubAgentActivityEvent { - event_id: call_id, - occurred_at_ms: now_unix_timestamp_ms(), - agent_thread_id: agent_id, - agent_path: receiver_agent_path, - kind: SubAgentActivityKind::Interrupted, - } - .into(), - ) - .await; + emit_sub_agent_activity( + &session, + &turn, + SubAgentActivityItem { + id: call_id, + agent_thread_id: agent_id, + agent_path: receiver_agent_path, + kind: SubAgentActivityKind::Interrupted, + }, + ) + .await; Ok(InterruptAgentResult { previous_status: status, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 9501b4355a..62defe65c4 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -7,7 +7,6 @@ use super::*; use crate::agent_communication::AgentCommunicationContext; use crate::agent_communication::AgentCommunicationKind; use crate::tools::context::FunctionToolOutput; -use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::protocol::InterAgentCommunication; #[derive(Clone, Copy, PartialEq, Eq)] @@ -115,19 +114,17 @@ pub(crate) async fn handle_message_string_tool( .await .map_err(|err| collab_agent_error(receiver_thread_id, err)); result?; - session - .send_event( - &turn, - SubAgentActivityEvent { - event_id: call_id, - occurred_at_ms: now_unix_timestamp_ms(), - agent_thread_id: receiver_thread_id, - agent_path: receiver_agent_path, - kind: SubAgentActivityKind::Interacted, - } - .into(), - ) - .await; + emit_sub_agent_activity( + &session, + &turn, + SubAgentActivityItem { + id: call_id, + agent_thread_id: receiver_thread_id, + agent_path: receiver_agent_path, + kind: SubAgentActivityKind::Interacted, + }, + ) + .await; Ok(FunctionToolOutput::from_text(String::new(), Some(true))) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 8eeb6ddd1b..1c68849c5e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -9,7 +9,6 @@ use crate::agent_communication::AgentCommunicationKind; use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2; use crate::tools::handlers::multi_agents_v2::message_tool::message_content; -use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::AgentPath; use codex_tools::ToolSpec; @@ -140,19 +139,17 @@ async fn handle_spawn_agent( .as_ref() .and_then(|snapshot| snapshot.session_source.get_nickname()) .or(spawned_agent.metadata.agent_nickname); - session - .send_event( - &turn, - SubAgentActivityEvent { - event_id: call_id, - occurred_at_ms: now_unix_timestamp_ms(), - agent_thread_id: new_thread_id, - agent_path: new_agent_path.clone(), - kind: SubAgentActivityKind::Started, - } - .into(), - ) - .await; + emit_sub_agent_activity( + &session, + &turn, + SubAgentActivityItem { + id: call_id, + agent_thread_id: new_thread_id, + agent_path: new_agent_path.clone(), + kind: SubAgentActivityKind::Started, + }, + ) + .await; let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); turn.session_telemetry.counter( "codex.multi_agent.spawn", diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs index e97f47238b..4e2eced260 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -2,7 +2,6 @@ use super::*; use crate::session::InputQueueActivity; use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v2; -use crate::turn_timing::now_unix_timestamp_ms; use codex_tools::ToolSpec; use std::collections::HashMap; use std::time::Duration; @@ -76,16 +75,20 @@ impl Handler { .await; session - .send_event( + .emit_turn_item_started( &turn, - CollabWaitingBeginEvent { - started_at_ms: now_unix_timestamp_ms(), + &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id.clone(), + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::InProgress, sender_thread_id: session.thread_id, receiver_thread_ids: Vec::new(), receiver_agents: Vec::new(), - call_id: call_id.clone(), - } - .into(), + prompt: None, + model: None, + reasoning_effort: None, + agents_states: Default::default(), + }), ) .await; @@ -94,16 +97,20 @@ impl Handler { let result = WaitAgentResult::from_outcome(outcome); session - .send_event( + .emit_turn_item_completed( &turn, - CollabWaitingEndEvent { + TurnItem::CollabAgentToolCall(CollabAgentToolCallItem { + id: call_id, + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::Completed, sender_thread_id: session.thread_id, - call_id, - completed_at_ms: now_unix_timestamp_ms(), - agent_statuses: Vec::new(), - statuses: HashMap::new(), - } - .into(), + receiver_thread_ids: Vec::new(), + receiver_agents: Vec::new(), + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }), ) .await; diff --git a/codex-rs/core/src/tools/hosted_spec.rs b/codex-rs/core/src/tools/hosted_spec.rs index 24a7905fa6..83996afcca 100644 --- a/codex-rs/core/src/tools/hosted_spec.rs +++ b/codex-rs/core/src/tools/hosted_spec.rs @@ -18,7 +18,7 @@ pub fn create_image_generation_tool(output_format: &str) -> ToolSpec { } pub fn create_web_search_tool(options: WebSearchToolOptions<'_>) -> Option { - let (external_web_access, index_gated_web_access) = match options.web_search_mode { + let (external_web_access, indexed_web_access) = match options.web_search_mode { Some(WebSearchMode::Cached) => (false, None), Some(WebSearchMode::Indexed) => (true, Some(true)), Some(WebSearchMode::Live) => (true, None), @@ -37,7 +37,7 @@ pub fn create_web_search_tool(options: WebSearchToolOptions<'_>) -> Option>>, + _execution_proxy: Option, } impl DeferredNetworkApproval { @@ -89,6 +92,7 @@ pub(crate) struct ActiveNetworkApproval { registration_id: Option, mode: NetworkApprovalMode, cancellation_token: CancellationToken, + execution_proxy: NetworkProxy, } impl ActiveNetworkApproval { @@ -100,11 +104,16 @@ impl ActiveNetworkApproval { self.cancellation_token.clone() } + pub(crate) fn execution_proxy(&self) -> &NetworkProxy { + &self.execution_proxy + } + pub(crate) fn into_deferred(self) -> Option { let ActiveNetworkApproval { registration_id, mode, cancellation_token, + execution_proxy, } = self; match (mode, registration_id) { (NetworkApprovalMode::Deferred, Some(registration_id)) => { @@ -112,6 +121,7 @@ impl ActiveNetworkApproval { registration_id, cancellation_token, finish_outcome: Arc::new(OnceCell::new()), + _execution_proxy: Some(execution_proxy), }) } _ => None, @@ -241,6 +251,11 @@ enum ActiveNetworkApprovalAttribution { Ambiguous, } +struct NetworkRequestAttribution { + owner_call: Option>, + environment_id: Option, +} + #[derive(Default)] struct NetworkApprovalCallState { active_calls: IndexMap>, @@ -305,9 +320,8 @@ impl NetworkApprovalService { async fn resolve_single_active_call(&self) -> Option> { let calls = self.calls.lock().await; - // Blocked proxy requests are not attributed to a specific tool call. Only pick an owner - // when there is exactly one candidate; with concurrent calls, canceling one would be a guess. - // TODO: Carry blocked-request attribution so concurrent active calls can be handled safely. + // Shared proxy requests can still arrive without an execution ID. Only pick an owner when + // there is exactly one candidate; with concurrent calls, canceling one would be a guess. if calls.active_calls.len() == 1 { return calls.active_calls.values().next().cloned(); } @@ -315,6 +329,18 @@ impl NetworkApprovalService { None } + async fn resolve_active_call_by_execution_id( + &self, + execution_id: &str, + ) -> Option> { + self.calls + .lock() + .await + .active_calls + .get(execution_id) + .cloned() + } + async fn resolve_active_call_attribution(&self) -> ActiveNetworkApprovalAttribution { let calls = self.calls.lock().await; match calls.active_calls.len() { @@ -327,6 +353,54 @@ impl NetworkApprovalService { } } + async fn resolve_request_attribution( + &self, + request: &NetworkPolicyRequest, + ) -> Option { + if let Some(execution_id) = request.execution_id.as_deref() { + let call = self + .resolve_active_call_by_execution_id(execution_id) + .await?; + let environment_id = request + .environment_id + .clone() + .unwrap_or_else(|| call.environment_id.clone()); + return (call.environment_id == environment_id).then_some(NetworkRequestAttribution { + owner_call: Some(call), + environment_id: Some(environment_id), + }); + } + + if let Some(environment_id) = request.environment_id.clone() { + let owner_call = match self.resolve_active_call_attribution().await { + ActiveNetworkApprovalAttribution::Single(call) => { + (call.environment_id == environment_id).then_some(call) + } + ActiveNetworkApprovalAttribution::None + | ActiveNetworkApprovalAttribution::Ambiguous => None, + }; + return Some(NetworkRequestAttribution { + owner_call, + environment_id: Some(environment_id), + }); + } + + match self.resolve_active_call_attribution().await { + ActiveNetworkApprovalAttribution::None => Some(NetworkRequestAttribution { + owner_call: None, + environment_id: None, + }), + ActiveNetworkApprovalAttribution::Single(call) => { + let environment_id = call.environment_id.clone(); + Some(NetworkRequestAttribution { + owner_call: Some(call), + environment_id: Some(environment_id), + }) + } + ActiveNetworkApprovalAttribution::Ambiguous => None, + } + } + async fn get_or_create_pending_approval( &self, key: HostApprovalKey, @@ -393,8 +467,12 @@ impl NetworkApprovalService { return; }; - self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(message)) - .await; + let outcome = NetworkApprovalOutcome::DeniedByPolicy(message); + if let Some(execution_id) = blocked.execution_id.as_deref() { + self.record_call_outcome(execution_id, outcome).await; + } else { + self.record_outcome_for_single_active_call(outcome).await; + } } async fn active_turn_context( @@ -431,28 +509,13 @@ impl NetworkApprovalService { NetworkProtocol::Socks5Tcp => NetworkApprovalProtocol::Socks5Tcp, NetworkProtocol::Socks5Udp => NetworkApprovalProtocol::Socks5Udp, }; - let (owner_call, active_environment_id) = - if let Some(environment_id) = request.environment_id.clone() { - let owner_call = match self.resolve_active_call_attribution().await { - ActiveNetworkApprovalAttribution::Single(call) => { - (call.environment_id == environment_id).then_some(call) - } - ActiveNetworkApprovalAttribution::None - | ActiveNetworkApprovalAttribution::Ambiguous => None, - }; - (owner_call, Some(environment_id)) - } else { - match self.resolve_active_call_attribution().await { - ActiveNetworkApprovalAttribution::None => (None, None), - ActiveNetworkApprovalAttribution::Single(call) => { - let environment_id = call.environment_id.clone(); - (Some(call), Some(environment_id)) - } - ActiveNetworkApprovalAttribution::Ambiguous => { - return NetworkDecision::deny(REASON_NOT_ALLOWED); - } - } - }; + let Some(NetworkRequestAttribution { + owner_call, + environment_id: active_environment_id, + }) = self.resolve_request_attribution(&request).await + else { + return NetworkDecision::deny(REASON_NOT_ALLOWED); + }; let turn_context = Self::active_turn_context(session.as_ref()).await; let Some(environment_id) = active_environment_id.or_else(|| { turn_context @@ -788,20 +851,39 @@ pub(crate) async fn begin_network_approval( session: &Session, turn_id: &str, managed_network_active: bool, + selected_sandbox: SandboxType, spec: Option, -) -> Option { +) -> Result, ToolError> { let NetworkApprovalSpec { network, mode, trigger, command, environment_id, - } = spec?; - if !managed_network_active || network.is_none() { - return None; + } = match spec { + Some(spec) => spec, + None => return Ok(None), + }; + let Some(network) = network else { + return Ok(None); + }; + if !managed_network_active { + return Ok(None); } let registration_id = Uuid::new_v4().to_string(); + let execution_proxy = if selected_sandbox == SandboxType::LinuxSeccomp { + let attribution_token = Uuid::new_v4().to_string(); + network + .for_execution(&environment_id, ®istration_id, attribution_token) + .map_err(|err| { + ToolError::Codex(codex_protocol::error::CodexErr::Io(io::Error::other( + format!("failed to create execution-scoped network proxy: {err}"), + ))) + })? + } else { + network.clone() + }; let cancellation_token = CancellationToken::new(); session .services @@ -816,11 +898,12 @@ pub(crate) async fn begin_network_approval( ) .await; - Some(ActiveNetworkApproval { + Ok(Some(ActiveNetworkApproval { registration_id: Some(registration_id), mode, cancellation_token, - }) + execution_proxy, + })) } pub(crate) async fn finish_immediate_network_approval( diff --git a/codex-rs/core/src/tools/network_approval_tests.rs b/codex-rs/core/src/tools/network_approval_tests.rs index 7a9ba86cef..4fdeb50ed7 100644 --- a/codex-rs/core/src/tools/network_approval_tests.rs +++ b/codex-rs/core/src/tools/network_approval_tests.rs @@ -285,6 +285,12 @@ fn denied_blocked_request(host: &str) -> BlockedRequest { }) } +fn denied_blocked_request_for_execution(host: &str, execution_id: &str) -> BlockedRequest { + let mut blocked = denied_blocked_request(host); + blocked.execution_id = Some(execution_id.to_string()); + blocked +} + async fn register_call_with_default_shell_trigger( service: &NetworkApprovalService, registration_id: &str, @@ -429,6 +435,7 @@ async fn deferred_finish_reuses_denial_result_after_first_consumer() { registration_id: "registration-1".to_string(), cancellation_token, finish_outcome: Arc::new(OnceCell::new()), + _execution_proxy: None, }; service .record_call_outcome( @@ -481,3 +488,27 @@ async fn record_blocked_request_ignores_ambiguous_unattributed_blocked_requests( assert_eq!(service.take_call_outcome("registration-1").await, None); assert_eq!(service.take_call_outcome("registration-2").await, None); } + +#[tokio::test] +async fn attributed_blocked_request_targets_one_of_multiple_active_calls() { + let service = NetworkApprovalService::default(); + let first = register_call_with_default_shell_trigger(&service, "registration-1").await; + let second = register_call_with_default_shell_trigger(&service, "registration-2").await; + + service + .record_blocked_request(denied_blocked_request_for_execution( + "example.com", + "registration-2", + )) + .await; + + assert!(!first.is_cancelled()); + assert!(second.is_cancelled()); + assert_eq!(service.take_call_outcome("registration-1").await, None); + assert_eq!( + service.take_call_outcome("registration-2").await, + Some(NetworkApprovalOutcome::DeniedByPolicy( + "Network access to \"example.com\" was blocked: domain is not on the allowlist for the current sandbox mode.".to_string() + )) + ); +} diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index aaa0dfe9d1..f82ad5bb70 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -9,6 +9,7 @@ caching). use crate::guardian::guardian_rejection_message; use crate::guardian::guardian_timeout_message; use crate::guardian::new_guardian_review_id; +use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::hook_runtime::run_permission_request_hooks; use crate::network_policy_decision::network_approval_context_from_payload; @@ -68,13 +69,18 @@ impl ToolOrchestrator { where T: ToolRuntime, { - let network_approval = begin_network_approval( + let network_approval = match begin_network_approval( &tool_ctx.session, &tool_ctx.turn.sub_id, managed_network_active, + attempt.sandbox, tool.network_approval_spec(req, tool_ctx), ) - .await; + .await + { + Ok(network_approval) => network_approval, + Err(err) => return (Err(err), None), + }; let attempt_tool_ctx = ToolCtx { session: tool_ctx.session.clone(), @@ -98,6 +104,9 @@ impl ToolOrchestrator { network_denial_cancellation_token: network_approval .as_ref() .map(ActiveNetworkApproval::cancellation_token), + network_proxy: network_approval + .as_ref() + .map(ActiveNetworkApproval::execution_proxy), }; let run_result = tool .run(req, &attempt_with_network_approval, &attempt_tool_ctx) @@ -274,6 +283,7 @@ impl ToolOrchestrator { .permissions .windows_sandbox_private_desktop, network_denial_cancellation_token: None, + network_proxy: None, }; let initial_attempt_start = Instant::now(); @@ -456,6 +466,7 @@ impl ToolOrchestrator { .permissions .windows_sandbox_private_desktop, network_denial_cancellation_token: None, + network_proxy: None, }; // Second attempt. @@ -563,7 +574,26 @@ impl ToolOrchestrator { } else { ToolDecisionSource::User }; - let decision = tool.start_approval_async(req, approval_ctx).await; + let decision = if let Some(review_id) = approval_ctx.guardian_review_id.clone() { + match tool.approval_action(req, &approval_ctx) { + Ok(action) => { + review_approval_request( + approval_ctx.session, + approval_ctx.turn, + review_id, + action, + approval_ctx.retry_reason.clone(), + ) + .await + } + Err(err) => { + tracing::error!(%err, "failed to build guardian approval action"); + ReviewDecision::Abort + } + } + } else { + tool.start_approval_async(req, approval_ctx).await + }; let tool_name = flat_tool_name(&tool_ctx.tool_name); otel.tool_decision( tool_name.as_ref(), diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 840a0dd01e..b839b05c44 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -4,11 +4,10 @@ //! selected turn environment filesystem for both local and remote turns, with //! sandboxing enforced by the explicit filesystem sandbox context. use crate::exec::is_likely_sandbox_denied; -use crate::guardian::GuardianApprovalRequest; -use crate::guardian::review_approval_request; use crate::session::turn_context::TurnEnvironment; use crate::tools::hook_names::HookToolName; use crate::tools::sandboxing::Approvable; +use crate::tools::sandboxing::ApprovalAction; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::PermissionRequestPayload; @@ -77,7 +76,7 @@ impl ApplyPatchRuntime { fn build_guardian_review_request( req: &ApplyPatchRequest, call_id: &str, - ) -> std::io::Result { + ) -> std::io::Result { // TODO(anp): Remove this conversion once the guardian API supports PathUri. let cwd = req.action.cwd.to_abs_path()?; let files = req @@ -85,7 +84,7 @@ impl ApplyPatchRuntime { .iter() .map(PathUri::to_abs_path) .collect::>>()?; - Ok(GuardianApprovalRequest::ApplyPatch { + Ok(ApprovalAction::ApplyPatch { id: call_id.to_string(), cwd, files, @@ -152,22 +151,7 @@ impl Approvable for ApplyPatchRuntime { let retry_reason = ctx.retry_reason.clone(); let approval_keys = self.approval_keys(req); let changes = req.changes.clone(); - let guardian_review_id = ctx.guardian_review_id.clone(); Box::pin(async move { - if let Some(review_id) = guardian_review_id { - let action = match ApplyPatchRuntime::build_guardian_review_request( - req, - ctx.call_id, - ) { - Ok(action) => action, - Err(err) => { - tracing::error!(cwd = %req.action.cwd, %err, "guardian apply_patch cwd is not host-native"); - return ReviewDecision::Abort; - } - }; - return review_approval_request(session, turn, review_id, action, retry_reason) - .await; - } if req.permissions_preapproved && retry_reason.is_none() { return ReviewDecision::Approved; } @@ -201,6 +185,14 @@ impl Approvable for ApplyPatchRuntime { }) } + fn approval_action( + &self, + req: &ApplyPatchRequest, + ctx: &ApprovalCtx<'_>, + ) -> std::io::Result { + ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id) + } + fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool { match policy { AskForApproval::Never => false, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index f1c6f43aa3..c70002e62f 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -80,7 +80,7 @@ async fn guardian_review_request_includes_patch_context() { assert_eq!( guardian_request, - GuardianApprovalRequest::ApplyPatch { + ApprovalAction::ApplyPatch { id: "call-1".to_string(), cwd: expected_cwd, files: vec![path], @@ -232,6 +232,7 @@ async fn file_system_sandbox_context_uses_active_attempt() { windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, windows_sandbox_private_desktop: true, network_denial_cancellation_token: None, + network_proxy: None, }; let sandbox = ApplyPatchRuntime::file_system_sandbox_context_for_attempt(&req, &attempt) @@ -300,6 +301,7 @@ async fn no_sandbox_attempt_has_no_file_system_context() { windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, network_denial_cancellation_token: None, + network_proxy: None, }; assert_eq!( diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 5c2614c9c0..f6ec38f6c2 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -118,6 +118,7 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, network_denial_cancellation_token: None, + network_proxy: None, }; let exec_request = attempt diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 0d386103db..3ff50f3f51 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -10,9 +10,7 @@ pub(crate) mod zsh_fork_backend; use crate::command_canonicalization::canonicalize_command_for_approval; use crate::exec::ExecCapturePolicy; -use crate::guardian::GuardianApprovalRequest; use crate::guardian::GuardianNetworkAccessTrigger; -use crate::guardian::review_approval_request; use crate::sandboxing::ExecOptions; use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; @@ -29,6 +27,7 @@ use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sand use crate::tools::runtimes::exec_env_for_sandbox_permissions; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::sandboxing::Approvable; +use crate::tools::sandboxing::ApprovalAction; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::PermissionRequestPayload; @@ -145,30 +144,14 @@ impl Approvable for ShellRuntime { let command = req.command.clone(); let cwd = req.cwd.clone(); let environment_id = Some(req.turn_environment.environment_id.clone()); - let retry_reason = ctx.retry_reason.clone(); - let reason = retry_reason.clone().or_else(|| req.justification.clone()); + let reason = ctx + .retry_reason + .clone() + .or_else(|| req.justification.clone()); let session = ctx.session; let turn = ctx.turn; let call_id = ctx.call_id.to_string(); - let guardian_review_id = ctx.guardian_review_id.clone(); Box::pin(async move { - if let Some(review_id) = guardian_review_id { - return review_approval_request( - session, - turn, - review_id, - GuardianApprovalRequest::Shell { - id: call_id, - command, - cwd: cwd.clone(), - sandbox_permissions: req.sandbox_permissions, - additional_permissions: req.additional_permissions.clone(), - justification: req.justification.clone(), - }, - retry_reason, - ) - .await; - } with_cached_approval(&session.services, "shell", keys, move || async move { let available_decisions = None; session @@ -193,6 +176,21 @@ impl Approvable for ShellRuntime { }) } + fn approval_action( + &self, + req: &ShellRequest, + ctx: &ApprovalCtx<'_>, + ) -> std::io::Result { + Ok(ApprovalAction::Shell { + id: ctx.call_id.to_string(), + command: req.command.clone(), + cwd: req.cwd.clone(), + sandbox_permissions: req.sandbox_permissions, + additional_permissions: req.additional_permissions.clone(), + justification: req.justification.clone(), + }) + } + fn exec_approval_requirement(&self, req: &ShellRequest) -> Option { Some(req.exec_approval_requirement.clone()) } diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 5df42cc979..a1b9b33c6d 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -7,9 +7,7 @@ the process manager to spawn PTYs once an ExecRequest is prepared. use crate::command_canonicalization::canonicalize_command_for_approval; use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; -use crate::guardian::GuardianApprovalRequest; use crate::guardian::GuardianNetworkAccessTrigger; -use crate::guardian::review_approval_request; use crate::sandboxing::ExecOptions; use crate::sandboxing::ExecServerEnvConfig; use crate::sandboxing::SandboxPermissions; @@ -26,6 +24,7 @@ use crate::tools::runtimes::exec_env_for_sandbox_permissions; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::runtimes::shell::zsh_fork_backend; use crate::tools::sandboxing::Approvable; +use crate::tools::sandboxing::ApprovalAction; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::PermissionRequestPayload; @@ -179,9 +178,10 @@ impl Approvable for UnifiedExecRuntime<'_> { let call_id = ctx.call_id.to_string(); let command = req.command.clone(); let environment_id = Some(req.turn_environment.environment_id.clone()); - let retry_reason = ctx.retry_reason.clone(); - let reason = retry_reason.clone().or_else(|| req.justification.clone()); - let guardian_review_id = ctx.guardian_review_id.clone(); + let reason = ctx + .retry_reason + .clone() + .or_else(|| req.justification.clone()); Box::pin(async move { let native_cwd = match req.cwd.to_abs_path() { Ok(c) => c, @@ -192,24 +192,6 @@ impl Approvable for UnifiedExecRuntime<'_> { return ReviewDecision::Abort; } }; - if let Some(review_id) = guardian_review_id { - return review_approval_request( - session, - turn, - review_id, - GuardianApprovalRequest::ExecCommand { - id: call_id, - command, - cwd: native_cwd.clone(), - sandbox_permissions: req.sandbox_permissions, - additional_permissions: req.additional_permissions.clone(), - justification: req.justification.clone(), - tty: req.tty, - }, - retry_reason, - ) - .await; - } with_cached_approval(&session.services, "unified_exec", keys, || async move { let available_decisions = None; session @@ -234,6 +216,22 @@ impl Approvable for UnifiedExecRuntime<'_> { }) } + fn approval_action( + &self, + req: &UnifiedExecRequest, + ctx: &ApprovalCtx<'_>, + ) -> std::io::Result { + Ok(ApprovalAction::ExecCommand { + id: ctx.call_id.to_string(), + command: req.command.clone(), + cwd: req.cwd.to_abs_path()?, + sandbox_permissions: req.sandbox_permissions, + additional_permissions: req.additional_permissions.clone(), + justification: req.justification.clone(), + tty: req.tty, + }) + } + fn exec_approval_requirement( &self, req: &UnifiedExecRequest, @@ -320,10 +318,10 @@ impl<'a> ToolRuntime for UnifiedExecRunt req.sandbox_permissions, &file_system_sandbox_policy, ); - let managed_network = managed_network_for_sandbox_permissions( + let managed_network = attempt.network_proxy(managed_network_for_sandbox_permissions( req.network.as_ref(), launch_sandbox_permissions, - ); + )); let env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions); let (env, managed_network_context) = match managed_network { Some(network) => { diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 8e9e274f24..571c5ce6df 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -133,6 +133,8 @@ pub(crate) struct ApprovalCtx<'a> { pub network_approval_context: Option, } +pub(crate) type ApprovalAction = crate::guardian::GuardianApprovalRequest; + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PermissionRequestPayload { pub tool_name: HookToolName, @@ -368,6 +370,8 @@ pub(crate) trait Approvable { req: &'a Req, ctx: ApprovalCtx<'a>, ) -> BoxFuture<'a, ReviewDecision>; + + fn approval_action(&self, req: &Req, ctx: &ApprovalCtx<'_>) -> std::io::Result; } pub(crate) trait Sandboxable { @@ -423,9 +427,17 @@ pub(crate) struct SandboxAttempt<'a> { pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, pub windows_sandbox_private_desktop: bool, pub network_denial_cancellation_token: Option, + pub(crate) network_proxy: Option<&'a NetworkProxy>, } impl<'a> SandboxAttempt<'a> { + pub(crate) fn network_proxy<'b>( + &'b self, + fallback: Option<&'b NetworkProxy>, + ) -> Option<&'b NetworkProxy> { + fallback.map(|fallback| self.network_proxy.unwrap_or(fallback)) + } + pub fn env_for( &self, command: SandboxCommand, @@ -433,6 +445,7 @@ impl<'a> SandboxAttempt<'a> { network: Option<&NetworkProxy>, environment_id: Option<&str>, ) -> Result { + let network = self.network_proxy(network); let request = self .manager .transform(SandboxTransformRequest { @@ -465,6 +478,7 @@ impl<'a> SandboxAttempt<'a> { network: Option<&NetworkProxy>, environment_id: Option<&str>, ) -> Result { + let network = self.network_proxy(network); let managed_network = command.managed_network.clone(); let exec_server_permissions = effective_permission_profile( self.exec_server_permissions, diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index 28b9de9ce1..e52186f772 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -227,6 +227,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, network_denial_cancellation_token: None, + network_proxy: None, }; let managed_network = ManagedNetworkSandboxContext { loopback_ports: vec![43123], diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index d95cb5ff44..d19744be46 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1609,7 +1609,7 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { live_web_search.visible_spec("web_search"), &ToolSpec::WebSearch { external_web_access: Some(true), - index_gated_web_access: None, + indexed_web_access: None, filters: None, user_location: None, search_context_size: None, diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 04f7aeb69b..d0f4f46cc3 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -334,6 +334,7 @@ fn response_event_records_turn_ttft(event: &ResponseEvent) -> bool { } ResponseEvent::OutputTextDelta(_) | ResponseEvent::ReasoningSummaryDelta { .. } + | ResponseEvent::ReasoningSummaryDone { .. } | ResponseEvent::ReasoningContentDelta { .. } => true, ResponseEvent::Created | ResponseEvent::ServerModel(_) diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 54ee788e3b..81403c8e52 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -160,7 +160,7 @@ async fn exec_command_with_tty( &output_closed, &output_closed_notify, &cancellation_token, - Some(session.subscribe_out_of_band_elicitation_pause_state()), + Some(session.subscribe_elicitation_pause_state()), deadline, ) .await; @@ -515,12 +515,11 @@ async fn unified_exec_pause_blocks_yield_timeout() -> anyhow::Result<()> { skip_if_sandbox!(Ok(())); let (session, turn) = test_session_and_turn().await; - session.set_out_of_band_elicitation_pause_state(/*paused*/ true); + let elicitation = session.services.elicitations.register(); - let paused_session = Arc::clone(&session); tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(2)).await; - paused_session.set_out_of_band_elicitation_pause_state(/*paused*/ false); + drop(elicitation); }); let started = tokio::time::Instant::now(); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index ef22918cd2..fe3ec45759 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -493,11 +493,7 @@ impl UnifiedExecProcessManager { &output_closed, &output_closed_notify, &cancellation_token, - Some( - context - .session - .subscribe_out_of_band_elicitation_pause_state(), - ), + Some(context.session.subscribe_elicitation_pause_state()), deadline, ) .await; @@ -577,9 +573,8 @@ impl UnifiedExecProcessManager { } } } else { - // Short‑lived command: emit ExecCommandEnd immediately using the - // same helper as the background watcher, so all end events share - // one implementation. + // Short-lived command: emit the completed command item immediately + // using the same helper as the background watcher. let finish_result = finish_deferred_network_approval_after_process_exit_for_session( Some(&context.session), deferred_network_approval.take(), @@ -839,7 +834,7 @@ impl UnifiedExecProcessManager { let pause_state = entry .session .upgrade() - .map(|session| session.subscribe_out_of_band_elicitation_pause_state()); + .map(|session| session.subscribe_elicitation_pause_state()); let session = entry.session.upgrade(); Ok(PreparedProcessHandles { diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 096cbe0a13..20a930b552 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -315,21 +315,24 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { let event = tokio::time::timeout(Duration::from_secs(1), rx_event.recv()) .await - .expect("timed out waiting for failed exec end event") + .expect("timed out waiting for failed command execution item") .expect("event channel closed"); - let codex_protocol::protocol::EventMsg::ExecCommandEnd(end_event) = event.msg else { - panic!("expected ExecCommandEnd event"); + let codex_protocol::protocol::EventMsg::ItemCompleted(completed_event) = event.msg else { + panic!("expected ItemCompleted event"); }; - assert_eq!(end_event.call_id, "call-unified-denied"); + let codex_protocol::items::TurnItem::CommandExecution(item) = completed_event.item else { + panic!("expected CommandExecution item"); + }; + assert_eq!(item.id, "call-unified-denied"); assert_eq!( - end_event.status, - codex_protocol::protocol::ExecCommandStatus::Failed + item.status, + codex_protocol::items::CommandExecutionStatus::Failed ); - assert_eq!(end_event.exit_code, -1); - assert_eq!(end_event.process_id.as_deref(), Some("123")); + assert_eq!(item.exit_code, Some(-1)); + assert_eq!(item.process_id.as_deref(), Some("123")); assert_eq!( - end_event.aggregated_output, - "PRE_DENIAL_MARKER\nNetwork access denied" + item.aggregated_output.as_deref(), + Some("PRE_DENIAL_MARKER\nNetwork access denied") ); } diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 9569df1c90..1ab3a70cbb 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -1569,6 +1569,45 @@ pub async fn mount_response_sequence( response_mock } +/// Mounts a sequence of responses for each POST to `/v1/responses/compact`. +/// Panics if more requests are received than responses provided. +pub async fn mount_compact_response_sequence( + server: &MockServer, + responses: Vec, +) -> ResponseMock { + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + struct SeqResponder { + num_calls: AtomicUsize, + responses: Vec, + } + + impl Respond for SeqResponder { + fn respond(&self, _: &wiremock::Request) -> ResponseTemplate { + let call_num = self.num_calls.fetch_add(1, Ordering::SeqCst); + self.responses + .get(call_num) + .expect("missing response for compact call") + .clone() + } + } + + let num_calls = responses.len(); + let responder = SeqResponder { + num_calls: AtomicUsize::new(0), + responses, + }; + + let (mock, response_mock) = compact_mock(); + mock.respond_with(responder) + .up_to_n_times(num_calls as u64) + .expect(num_calls as u64) + .mount(server) + .await; + response_mock +} + /// Validate invariants on the request body sent to `/v1/responses`. /// /// - No `function_call_output`/`custom_tool_call_output` with missing/empty `call_id`. diff --git a/codex-rs/core/tests/remote_env_windows/BUILD.bazel b/codex-rs/core/tests/remote_env_windows/BUILD.bazel index 9b5ce52bd9..6ad412da2b 100644 --- a/codex-rs/core/tests/remote_env_windows/BUILD.bazel +++ b/codex-rs/core/tests/remote_env_windows/BUILD.bazel @@ -6,15 +6,10 @@ wine_rust_test( srcs = ["remote_env_windows_test.rs"], crate_name = "remote_env_windows_test", crate_root = "remote_env_windows_test.rs", - host_binaries = { - "codex-app-server": "//codex-rs/app-server:codex-app-server", - }, windows_binaries = { - "wine-windows-exec-server": "//codex-rs/exec-server/testing:windows-exec-server", + "wine-windows-exec-server": "//codex-rs/exec-server/testing:exec-server", }, deps = [ - "//codex-rs/app-server-protocol", - "//codex-rs/app-server/tests/common", "//codex-rs/core/tests/common", "//codex-rs/exec-server", "//codex-rs/exec-server/testing:wine-exec-server-test-support", @@ -24,7 +19,6 @@ wine_rust_test( "@crates//:anyhow", "@crates//:pretty_assertions", "@crates//:serde_json", - "@crates//:tempfile", "@crates//:tokio", ], ) diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index 04f8914982..ff80316061 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -2,20 +2,7 @@ use anyhow::Context; use anyhow::Result; -use app_test_support::PathBufExt; -use app_test_support::TestAppServer; -use app_test_support::create_mock_responses_server_repeating_assistant; -use app_test_support::to_response; -use app_test_support::write_mock_responses_config_toml; -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::ThreadStartParams; -use codex_app_server_protocol::ThreadStartResponse; -use codex_app_server_protocol::TurnEnvironmentParams; -use codex_app_server_protocol::TurnStartParams; -use codex_app_server_protocol::TurnStartResponse; -use codex_app_server_protocol::UserInput as V2UserInput; use codex_exec_server::REMOTE_ENVIRONMENT_ID; -use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_features::Feature; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -35,20 +22,11 @@ use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; -use codex_utils_path_uri::LegacyAppPathString; -use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; -use serde_json::Value; use serde_json::json; -use std::collections::BTreeMap; -use std::fs; -use tempfile::TempDir; -use tokio::time::timeout; use wine_exec_server_test_support::WineExecServer; -const APP_SERVER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { const CALL_ID: &str = "wine-cmd-smoke"; @@ -244,152 +222,3 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { }) .await } - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Result<()> { - const AGENTS_INSTRUCTIONS: &str = "remote Windows workspace instructions"; - const NATIVE_CWD: &str = r"C:\windows"; - - WineExecServer - .scope(|exec_server_url, wine_prefix| async move { - let agents_path = PathUri::parse("file:///C:/windows/AGENTS.md")?; - fs::write( - wine_prefix.join("drive_c").join("windows").join("AGENTS.md"), - AGENTS_INSTRUCTIONS, - )?; - - let codex_home = TempDir::new()?; - let server = create_mock_responses_server_repeating_assistant("done").await; - write_mock_responses_config_toml( - codex_home.path(), - &server.uri(), - &BTreeMap::new(), - 100_000, - /*requires_openai_auth*/ None, - "mock", - "compact", - )?; - let mut app_server = TestAppServer::new_with_env( - codex_home.path(), - &[( - CODEX_EXEC_SERVER_URL_ENV_VAR, - Some(exec_server_url.as_str()), - )], - ) - .await?; - timeout(APP_SERVER_READ_TIMEOUT, app_server.initialize()).await??; - - let request_id = app_server - .send_thread_start_request(ThreadStartParams { - environments: Some(vec![TurnEnvironmentParams { - environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: serde_json::from_value::(json!(NATIVE_CWD))?, - }]), - ..Default::default() - }) - .await?; - let response = timeout( - APP_SERVER_READ_TIMEOUT, - app_server.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let response: ThreadStartResponse = to_response(response)?; - assert!(!response.thread.id.is_empty()); - let host_cwd = codex_home.path().to_path_buf().abs(); - // TODO(anp): Return the selected environment's native cwd from thread/start. - assert_eq!(response.cwd, host_cwd); - // TODO(anp): Derive runtime workspace roots from the selected remote environment. - assert_eq!(response.runtime_workspace_roots, vec![host_cwd]); - assert_eq!( - response.instruction_sources, - vec![LegacyAppPathString::from_path_uri( - &agents_path, - PathConvention::Windows, - )?] - ); - // TODO(anp): Report the implicit built-in permission profile instead of None. - assert_eq!(response.active_permission_profile, None); - - let turn_request_id = app_server - .send_turn_start_request(TurnStartParams { - thread_id: response.thread.id, - client_user_message_id: None, - input: vec![V2UserInput::Text { - text: "say done".to_string(), - text_elements: Vec::new(), - }], - ..Default::default() - }) - .await?; - let turn_response = timeout( - APP_SERVER_READ_TIMEOUT, - app_server.read_stream_until_response_message(RequestId::Integer(turn_request_id)), - ) - .await??; - let _: TurnStartResponse = to_response(turn_response)?; - timeout( - APP_SERVER_READ_TIMEOUT, - app_server.read_stream_until_notification_message("turn/completed"), - ) - .await??; - - let requests = server - .received_requests() - .await - .context("failed to fetch received requests")?; - let first_request = requests - .iter() - .find(|request| request.url.path().ends_with("/responses")) - .context("turn should send a Responses request")?; - let body = first_request.body_json::()?; - let remote_instructions = body["input"] - .as_array() - .into_iter() - .flatten() - .filter(|item| item.get("role").and_then(Value::as_str) == Some("user")) - .filter_map(|item| item.get("content").and_then(Value::as_array)) - .flatten() - .filter_map(|content| content.get("text").and_then(Value::as_str)) - .find(|text| text.contains(AGENTS_INSTRUCTIONS)) - .context("remote workspace instructions should be model visible")?; - assert!(remote_instructions.contains(r"# AGENTS.md instructions for C:\windows")); - let environment_context = body["input"] - .as_array() - .into_iter() - .flatten() - .filter(|item| item.get("role").and_then(Value::as_str) == Some("user")) - .filter_map(|item| item.get("content").and_then(Value::as_array)) - .flatten() - .filter_map(|content| content.get("text").and_then(Value::as_str)) - .find(|text| text.starts_with("")) - .context("environment context should be model visible")?; - // The model should see the remote environment's shell, not the Linux app-server's - // host shell. - assert_eq!( - environment_context - .lines() - .find(|line| line.trim_start().starts_with("")) - .map(str::trim), - Some("powershell"), - ); - // The model should see cwd using the remote environment's native path convention, not - // the Linux app-server's host path convention. - assert_eq!( - environment_context - .lines() - .find(|line| line.trim_start().starts_with("")) - .map(str::trim), - Some(r"C:\windows"), - ); - let host_workspace_roots = format!( - "{}", - codex_home.path().display() - ); - // TODO(anp): Derive model-visible workspace roots from the selected remote environment - // and render them using its native path convention. - assert!(environment_context.contains(&host_workspace_roots)); - - Ok(()) - }) - .await -} diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 5b7c2c65b8..b31b6d5682 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -131,7 +131,9 @@ async fn responses_stream_includes_subagent_header_on_review() { /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, + config.http_client_factory(), ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); let mut client_session = client.new_session(); @@ -265,7 +267,9 @@ async fn responses_stream_includes_subagent_header_on_other() { /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, + config.http_client_factory(), ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); let mut client_session = client.new_session(); @@ -385,7 +389,9 @@ async fn responses_respects_model_info_overrides_from_config() { /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, + config.http_client_factory(), ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); let mut client_session = client.new_session(); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index c87539f940..4d298998af 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1334,7 +1334,12 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), + /*concurrent_reasoning_summaries_enabled*/ + config + .features + .enabled(Feature::ConcurrentReasoningSummaries), /*attestation_provider*/ None, + config.http_client_factory(), ); let responses_metadata = test_turn_responses_metadata(&client, thread_id); let mut client_session = client.new_session(); @@ -2376,6 +2381,9 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.model_reasoning_summary = Some(ReasoningSummary::Concise); + let _ = config + .features + .enable(Feature::ConcurrentReasoningSummaries); }) .build(&server) .await?; @@ -2412,6 +2420,55 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { .and_then(|reasoning| reasoning.get("context")), None ); + pretty_assertions::assert_eq!( + request_body + .get("stream_options") + .and_then(|stream_options| stream_options.get("reasoning_summary_delivery")) + .and_then(|value| value.as_str()), + Some("sequential_cutoff") + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sequential_cutoff_is_omitted_for_non_openai_provider() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let server = MockServer::start().await; + + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + let TestCodex { codex, .. } = test_codex() + .with_config(|config| { + config.model_provider.name = "mock".to_string(); + let _ = config + .features + .enable(Feature::ConcurrentReasoningSummaries); + }) + .build(&server) + .await?; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request_body = resp_mock.single_request().body_json(); + pretty_assertions::assert_eq!(request_body.get("stream_options"), None); Ok(()) } @@ -2583,6 +2640,7 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> { .and_then(|reasoning| reasoning.get("summary")), None ); + pretty_assertions::assert_eq!(request_body.get("stream_options"), None); Ok(()) } @@ -2948,7 +3006,9 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, + config.http_client_factory(), ); let responses_metadata = test_turn_responses_metadata(&client, thread_id); let mut client_session = client.new_session(); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index d5218cdcab..05a93807b2 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -386,7 +386,13 @@ async fn responses_websocket_request_prewarm_reuses_connection() { ]]) .await; - let harness = websocket_harness_with_options(&server, /*runtime_metrics_enabled*/ true).await; + let mut provider = websocket_provider(&server); + provider.name = ModelProviderInfo::create_openai_provider(/*base_url*/ None).name; + let harness = websocket_harness_with_provider_options( + provider, /*runtime_metrics_enabled*/ true, + /*concurrent_reasoning_summaries_enabled*/ true, + ) + .await; let mut client_session = harness.client.new_session(); let prompt = prompt_with_input(vec![message_item("hello")]); let responses_metadata = prewarm_metadata(&harness, /*turn_id*/ None); @@ -422,6 +428,10 @@ async fn responses_websocket_request_prewarm_reuses_connection() { assert_eq!(warmup["type"].as_str(), Some("response.create")); assert_eq!(warmup["generate"].as_bool(), Some(false)); + assert_eq!( + warmup["stream_options"]["reasoning_summary_delivery"].as_str(), + Some("sequential_cutoff") + ); assert_eq!(warmup["tools"], serde_json::json!([])); let warmup_turn_metadata: serde_json::Value = serde_json::from_str( warmup["client_metadata"]["x-codex-turn-metadata"] @@ -436,6 +446,10 @@ async fn responses_websocket_request_prewarm_reuses_connection() { assert_eq!(follow_up["type"].as_str(), Some("response.create")); assert_eq!(follow_up["previous_response_id"].as_str(), Some("warm-1")); assert_eq!(follow_up["input"], serde_json::json!([])); + assert_eq!( + follow_up["stream_options"]["reasoning_summary_delivery"].as_str(), + Some("sequential_cutoff") + ); server.shutdown().await; } @@ -990,8 +1004,11 @@ async fn responses_websocket_v2_incremental_requests_are_reused_across_turns() { // use OpenAI provider to check metadata logic let mut provider = websocket_provider(&server); provider.name = ModelProviderInfo::create_openai_provider(/*base_url*/ None).name; - let harness = - websocket_harness_with_provider_options(provider, /*runtime_metrics_enabled*/ false).await; + let harness = websocket_harness_with_provider_options( + provider, /*runtime_metrics_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, + ) + .await; // Turn one: initiate let prompt_one = prompt_with_input(vec![message_item("hello")]); @@ -2195,13 +2212,18 @@ async fn websocket_harness_with_options( server: &WebSocketTestServer, runtime_metrics_enabled: bool, ) -> WebsocketTestHarness { - websocket_harness_with_provider_options(websocket_provider(server), runtime_metrics_enabled) - .await + websocket_harness_with_provider_options( + websocket_provider(server), + runtime_metrics_enabled, + /*concurrent_reasoning_summaries_enabled*/ false, + ) + .await } async fn websocket_harness_with_provider_options( provider: ModelProviderInfo, runtime_metrics_enabled: bool, + concurrent_reasoning_summaries_enabled: bool, ) -> WebsocketTestHarness { let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home).await; @@ -2212,6 +2234,12 @@ async fn websocket_harness_with_provider_options( .enable(Feature::RuntimeMetrics) .expect("test config should allow feature update"); } + if concurrent_reasoning_summaries_enabled { + config + .features + .enable(Feature::ConcurrentReasoningSummaries) + .expect("test config should allow feature update"); + } let config = Arc::new(config); let model_info = codex_core::test_support::construct_model_info_offline(MODEL, &config); let thread_id = ThreadId::new(); @@ -2251,7 +2279,12 @@ async fn websocket_harness_with_provider_options( runtime_metrics_enabled, /*beta_features_header*/ None, /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), + /*concurrent_reasoning_summaries_enabled*/ + config + .features + .enabled(Feature::ConcurrentReasoningSummaries), /*attestation_provider*/ None, + config.http_client_factory(), ); WebsocketTestHarness { diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 26c041d8f7..9293b506ef 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -521,6 +521,76 @@ text(JSON.stringify(await tools.exec_command({ cmd: "printf code_mode_exec_marke Ok(()) } +#[tokio::test] +async fn code_mode_exec_holds_captured_result_during_elicitation() -> Result<()> { + let server = responses::start_mock_server().await; + let mut builder = test_codex() + .with_model("test-gpt-5.1-codex") + .with_config(|config| { + let _ = config.features.enable(Feature::CodeMode); + }); + let test = builder.build(&server).await?; + + let first_mock = responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_custom_tool_call("call-1", "exec", "text('captured');"), + ev_completed("resp-1"), + ]), + ) + .await; + let second_mock = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + assert_eq!( + test.codex.increment_out_of_band_elicitation_count().await?, + 1 + ); + assert_eq!( + test.codex.increment_out_of_band_elicitation_count().await?, + 2 + ); + let release_elicitation = async { + tokio::time::timeout(Duration::from_secs(5), async { + while first_mock.requests().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("initial response request should arrive"); + tokio::time::sleep(Duration::from_millis(250)).await; + assert!( + second_mock.requests().is_empty(), + "captured exec result should not return during an elicitation" + ); + assert_eq!( + test.codex.decrement_out_of_band_elicitation_count().await?, + 1 + ); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + second_mock.requests().is_empty(), + "captured exec result should wait for every elicitation" + ); + assert_eq!( + test.codex.decrement_out_of_band_elicitation_count().await?, + 0 + ); + Ok::<(), anyhow::Error>(()) + }; + + tokio::try_join!(test.submit_turn("run code mode"), release_elicitation)?; + second_mock.single_request(); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_only_restricts_prompt_tools() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 3f9b56e841..214039f3d7 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -48,6 +48,7 @@ use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_function_call; use core_test_support::responses::mount_compact_json_once; +use core_test_support::responses::mount_compact_response_sequence; use core_test_support::responses::mount_response_sequence; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_once_match; @@ -274,6 +275,20 @@ fn non_openai_model_provider(server: &MockServer) -> ModelProviderInfo { provider } +fn openai_model_provider(server: &MockServer) -> ModelProviderInfo { + let mut provider = + built_in_model_providers(/* openai_base_url */ /*openai_base_url*/ None)["openai"].clone(); + provider.base_url = Some(format!("{}/v1", server.uri())); + provider.supports_websockets = false; + provider +} + +fn invalid_request_response(message: impl Into) -> wiremock::ResponseTemplate { + wiremock::ResponseTemplate::new(/*status*/ 400).set_body_json(json!({ + "detail": message.into(), + })) +} + fn write_global_file( home: &TempDir, filename: &str, @@ -421,6 +436,7 @@ async fn assert_compaction_uses_turn_lifecycle_id(codex: &std::sync::Arc compact_completed_id = Some(event.id.clone()), + EventMsg::Error(error) => panic!("unexpected compaction error: {error:?}"), EventMsg::TurnComplete(_) => turn_completed_id = Some(event.id.clone()), _ => {} } @@ -2276,6 +2292,439 @@ async fn pre_sampling_compact_runs_when_comp_hash_changes() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_sampling_compact_falls_back_from_retired_previous_model_after_rename() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let retired_model = "gpt-5.5"; + let previous_model_family = "gpt-5.5"; + let renamed_model = "gpt-5.6"; + let mut previous_model_info = model_info_with_optional_comp_hash("gpt-5.4", Some("hash-a")); + previous_model_info.slug = previous_model_family.to_string(); + let mut renamed_model_info = model_info_with_optional_comp_hash("gpt-5.4", Some("hash-b")); + renamed_model_info.slug = renamed_model.to_string(); + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![previous_model_info, renamed_model_info], + }, + ) + .await; + + let request_log = mount_response_sequence( + &server, + vec![ + sse_response(sse(vec![ + ev_assistant_message("m1", "before switch"), + ev_completed_with_tokens("r1", /*total_tokens*/ 100), + ])), + invalid_request_response(format!( + "The '{retired_model}' model is not supported when using Codex with a ChatGPT account." + )), + sse_response(sse(vec![ + json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": "RETIRED_MODEL_SUMMARY", + } + }), + ev_completed_with_tokens("r2", /*total_tokens*/ 10), + ])), + sse_response(sse(vec![ + ev_assistant_message("m3", "after switch"), + ev_completed_with_tokens("r3", /*total_tokens*/ 100), + ])), + ], + ) + .await; + + let model_provider = openai_model_provider(&server); + let mut initial_builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(retired_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + }); + let initial = initial_builder + .build(&server) + .await + .expect("build initial test codex"); + let home = initial.home.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + initial + .codex + .submit(disabled_permission_user_turn( + "before switch", + initial.cwd.path().to_path_buf(), + retired_model.to_string(), + )) + .await + .expect("submit first user turn"); + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + initial + .codex + .submit(Op::Shutdown) + .await + .expect("shutdown initial session"); + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::ShutdownComplete) + }) + .await; + + let model_provider = openai_model_provider(&server); + let mut resumed_builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(retired_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + }); + let resumed = resumed_builder + .resume(&server, home, rollout_path) + .await + .expect("resume codex"); + + resumed + .codex + .submit(disabled_permission_user_turn( + "after switch", + resumed.cwd.path().to_path_buf(), + renamed_model.to_string(), + )) + .await + .expect("submit renamed-model turn"); + assert_compaction_uses_turn_lifecycle_id(&resumed.codex).await; + + let requests = request_log.requests(); + assert_eq!(models_mock.requests().len(), 1); + assert_eq!( + requests.len(), + 4, + "the renamed-model turn should retry compaction and then sample" + ); + assert_eq!( + requests[0].body_json()["model"].as_str(), + Some(retired_model) + ); + assert_eq!( + requests[1].body_json()["model"].as_str(), + Some(retired_model) + ); + assert_eq!( + requests[2].body_json()["model"].as_str(), + Some(renamed_model) + ); + assert_eq!( + requests[3].body_json()["model"].as_str(), + Some(renamed_model) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_sampling_compact_falls_back_after_previous_model_invalid_request_on_downshift() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let retired_model = "gpt-5.6"; + let previous_model_family = "gpt-5.6"; + let next_model = "gpt-5.5"; + let mut previous_model_info = + model_info_with_context_window("gpt-5.4", /*context_window*/ 273_000); + previous_model_info.slug = previous_model_family.to_string(); + let mut next_model_info = + model_info_with_context_window("gpt-5.4", /*context_window*/ 125_000); + next_model_info.slug = next_model.to_string(); + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![previous_model_info, next_model_info], + }, + ) + .await; + + let request_log = mount_response_sequence( + &server, + vec![ + sse_response(sse(vec![ + ev_assistant_message("m1", "before switch"), + ev_completed_with_tokens("r1", /*total_tokens*/ 120_000), + ])), + invalid_request_response("previous-model compaction was rejected"), + sse_response(sse(vec![ + json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": "DOWNSHIFT_SUMMARY", + } + }), + ev_completed_with_tokens("r2", /*total_tokens*/ 10), + ])), + sse_response(sse(vec![ + ev_assistant_message("m3", "after switch"), + ev_completed_with_tokens("r3", /*total_tokens*/ 100), + ])), + ], + ) + .await; + + let model_provider = openai_model_provider(&server); + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(retired_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + let _ = config.features.enable(Feature::RemoteCompactionV2); + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.codex + .submit(disabled_permission_user_turn( + "before switch", + test.cwd.path().to_path_buf(), + retired_model.to_string(), + )) + .await + .expect("submit first user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + test.codex + .submit(disabled_permission_user_turn( + "after switch", + test.cwd.path().to_path_buf(), + next_model.to_string(), + )) + .await + .expect("submit smaller-model turn"); + assert_compaction_uses_turn_lifecycle_id(&test.codex).await; + + let requests = request_log.requests(); + assert_eq!(models_mock.requests().len(), 1); + assert_eq!( + requests.len(), + 4, + "the smaller-model turn should retry compaction and then sample" + ); + assert_eq!( + requests[0].body_json()["model"].as_str(), + Some(retired_model) + ); + assert_eq!( + requests[1].body_json()["model"].as_str(), + Some(retired_model) + ); + assert_eq!(requests[2].body_json()["model"].as_str(), Some(next_model)); + assert_eq!(requests[3].body_json()["model"].as_str(), Some(next_model)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_sampling_legacy_remote_compact_falls_back_after_previous_model_invalid_request() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let retired_model = "gpt-5.6"; + let previous_model_family = "gpt-5.6"; + let next_model = "gpt-5.5"; + let mut previous_model_info = + model_info_with_context_window("gpt-5.4", /*context_window*/ 273_000); + previous_model_info.slug = previous_model_family.to_string(); + let mut next_model_info = + model_info_with_context_window("gpt-5.4", /*context_window*/ 125_000); + next_model_info.slug = next_model.to_string(); + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![previous_model_info, next_model_info], + }, + ) + .await; + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "before switch"), + ev_completed_with_tokens("r1", /*total_tokens*/ 120_000), + ]), + sse(vec![ + ev_assistant_message("m3", "after switch"), + ev_completed_with_tokens("r3", /*total_tokens*/ 100), + ]), + ], + ) + .await; + let compact_request_log = mount_compact_response_sequence( + &server, + vec![ + invalid_request_response("previous-model compaction was rejected"), + wiremock::ResponseTemplate::new(/*status*/ 200) + .insert_header("content-type", "application/json") + .set_body_json(json!({ + "output": [{ + "type": "compaction", + "encrypted_content": "DOWNSHIFT_SUMMARY", + }], + })), + ], + ) + .await; + + let model_provider = openai_model_provider(&server); + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(retired_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + let _ = config.features.disable(Feature::RemoteCompactionV2); + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.codex + .submit(disabled_permission_user_turn( + "before switch", + test.cwd.path().to_path_buf(), + retired_model.to_string(), + )) + .await + .expect("submit first user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + test.codex + .submit(disabled_permission_user_turn( + "after switch", + test.cwd.path().to_path_buf(), + next_model.to_string(), + )) + .await + .expect("submit smaller-model turn"); + assert_compaction_uses_turn_lifecycle_id(&test.codex).await; + + let requests = request_log.requests(); + let compact_requests = compact_request_log.requests(); + assert_eq!(models_mock.requests().len(), 1); + assert_eq!(requests.len(), 2); + assert_eq!(compact_requests.len(), 2); + assert_eq!( + requests[0].body_json()["model"].as_str(), + Some(retired_model) + ); + assert_eq!( + compact_requests[0].body_json()["model"].as_str(), + Some(retired_model) + ); + assert_eq!( + compact_requests[1].body_json()["model"].as_str(), + Some(next_model) + ); + assert_eq!(requests[1].body_json()["model"].as_str(), Some(next_model)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_sampling_compact_keeps_unknown_previous_model_for_api_key_auth_and_custom_provider() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let previous_model = "custom/gpt-5.5"; + let previous_model_family = "gpt-5.5"; + let next_model = "gpt-5.6"; + let mut previous_model_info = model_info_with_optional_comp_hash("gpt-5.4", Some("hash-a")); + previous_model_info.slug = previous_model_family.to_string(); + let mut next_model_info = model_info_with_optional_comp_hash("gpt-5.4", Some("hash-b")); + next_model_info.slug = next_model.to_string(); + + let model_catalog = ModelsResponse { + models: vec![previous_model_info, next_model_info], + }; + + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "before switch"), + ev_completed_with_tokens("r1", /*total_tokens*/ 100), + ]), + sse(vec![ + ev_assistant_message("m2", "CUSTOM_MODEL_SUMMARY"), + ev_completed_with_tokens("r2", /*total_tokens*/ 10), + ]), + sse(vec![ + ev_assistant_message("m3", "after switch"), + ev_completed_with_tokens("r3", /*total_tokens*/ 100), + ]), + ], + ) + .await; + + let model_provider = non_openai_model_provider(&server); + let mut builder = test_codex() + .with_auth(CodexAuth::from_api_key("Test API Key")) + .with_model(previous_model) + .with_config(move |config| { + config.model_provider = model_provider; + config.model_catalog = Some(model_catalog); + set_test_compact_prompt(config); + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.codex + .submit(disabled_permission_user_turn( + "before switch", + test.cwd.path().to_path_buf(), + previous_model.to_string(), + )) + .await + .expect("submit first user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + test.codex + .submit(disabled_permission_user_turn( + "after switch", + test.cwd.path().to_path_buf(), + next_model.to_string(), + )) + .await + .expect("submit next-model turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 3); + assert_pre_sampling_switch_compaction_requests( + &requests[0].body_json(), + &requests[1].body_json(), + &requests[2].body_json(), + previous_model, + next_model, + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pre_sampling_compact_skips_when_either_comp_hash_is_missing() { skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index d0e69d6569..e22f70b052 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -209,6 +209,7 @@ async fn start_realtime_conversation(codex: &codex_core::CodexThread) -> Result< codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 6f56ce016d..d4042a8c2e 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -941,6 +941,19 @@ fn normalize_string(value: &str) -> String { normalize_tmp_prefix_before_marker(&mut text, "/skills/"); normalize_tmp_prefix_before_marker(&mut text, "\\skills\\"); + let skills_open_tag = ""; + let skills_close_tag = ""; + let mut search_start = 0; + while let Some(relative_start) = text[search_start..].find(skills_open_tag) { + let body_start = search_start + relative_start + skills_open_tag.len(); + let Some(relative_end) = text[body_start..].find(skills_close_tag) else { + break; + }; + let body_end = body_start + relative_end; + text.replace_range(body_start..body_end, "\n...\n"); + search_start = body_start + "\n...\n".len() + skills_close_tag.len(); + } + let mut search_start = 0; let wall_time_prefix = "Wall time: "; let wall_time_suffix = " seconds"; @@ -964,6 +977,19 @@ fn normalize_string(value: &str) -> String { text } +#[test] +fn normalize_string_rewrites_dynamic_skill_instructions() { + let text = normalize_string( + "before\n\n## Skills\n- demo: Dynamic description\n\ + \nafter", + ); + + assert_eq!( + text, + "before\n\n...\n\nafter" + ); +} + fn is_uuid_like(value: &str) -> bool { let bytes = value.as_bytes(); bytes.len() == 36 diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 4e64c66b0a..0c73b74ab2 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -1,6 +1,7 @@ #![allow(clippy::unwrap_used)] use anyhow::Result; +use codex_config::test_support::CloudConfigBundleFixture; use codex_features::Feature; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; @@ -17,6 +18,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_target_windows; use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; @@ -255,6 +257,84 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { Ok(()) } +#[tokio::test] +async fn malformed_custom_rules_preserve_managed_forbidden_prefix() -> Result<()> { + skip_if_target_windows!( + Ok(()), + "managed prefix fixture uses POSIX executable semantics" + ); + + let mut builder = test_codex() + .with_cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[rules] +prefix_rules = [ + { pattern = [{ token = "echo" }], decision = "forbidden" }, +] +"#, + ), + ) + .with_config(|config| { + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + let policy_path = config.codex_home.join("rules").join("broken.rules"); + fs::create_dir_all( + policy_path + .parent() + .expect("policy directory must have a parent"), + ) + .expect("create policy directory"); + fs::write(policy_path, "prefix_rule(").expect("write malformed policy file"); + }); + let server = start_mock_server().await; + let test = builder.build_with_auto_env(&server).await?; + let call_id = "managed-shell-forbidden"; + let args = json!({ + "cmd": "echo blocked", + "yield_time_ms": 1_000, + }); + + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-managed-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-managed-1"), + ]), + ) + .await; + let results_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-managed-1", "done"), + ev_completed("resp-managed-2"), + ]), + ) + .await; + + test.submit_turn_with_approval_and_permission_profile( + "run shell command", + AskForApproval::Never, + PermissionProfile::Disabled, + ) + .await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("function call output should include a string output payload"); + assert!( + output.contains("policy forbids commands starting with `echo`"), + "unexpected output: {output}" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_command_empty_script_with_collaboration_mode_does_not_panic() -> Result<()> { let server = start_mock_server().await; diff --git a/codex-rs/core/tests/suite/external_auth.rs b/codex-rs/core/tests/suite/external_auth.rs new file mode 100644 index 0000000000..2aa9c9a0d5 --- /dev/null +++ b/codex-rs/core/tests/suite/external_auth.rs @@ -0,0 +1,42 @@ +use codex_login::AuthHeaders; +use codex_login::CodexAuth; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use reqwest::header::AUTHORIZATION; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderValue; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn header_auth_is_attached_to_responses_requests() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer external")); + headers.insert("x-external-auth", HeaderValue::from_static("enabled")); + let mut builder = test_codex().with_auth(CodexAuth::Headers(AuthHeaders::new(headers))); + let test = builder.build_with_auto_env(&server).await?; + + test.submit_turn("hello").await?; + + let request = response_mock.single_request(); + assert_eq!( + request.header("authorization").as_deref(), + Some("Bearer external") + ); + assert_eq!( + request.header("x-external-auth").as_deref(), + Some("enabled") + ); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index c1da262033..383954b816 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -20,6 +20,7 @@ use core_test_support::responses::start_mock_server; use core_test_support::responses::start_websocket_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; +use core_test_support::skip_if_wine_exec; use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; @@ -110,6 +111,121 @@ async fn guardian_session_prewarms_and_is_reused_for_first_review() -> Result<() Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn guardian_denial_rejects_tool_call_with_rationale() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + skip_if_wine_exec!( + Ok(()), + "Guardian approval actions require host-native paths" + ); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config + .set_legacy_sandbox_policy(sandbox_policy_for_config) + .expect("set sandbox policy"); + }); + let test = builder.build_with_auto_env(&server).await?; + + let output_file = test.cwd.path().join("guardian-denied.txt"); + let command = format!("printf should-not-run > {}", output_file.display()); + let tool_args = json!({ + "cmd": command, + "yield_time_ms": 1_000_u64, + "sandbox_permissions": SandboxPermissions::RequireEscalated, + "justification": "Exercise Guardian denial routing.", + }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-parent-tool-denied"), + ev_function_call( + "exec-call-denied", + "exec_command", + &serde_json::to_string(&tool_args)?, + ), + ev_completed("resp-parent-tool-denied"), + ]), + sse(vec![ + ev_response_created("resp-guardian-denied"), + ev_assistant_message( + "msg-guardian-denied", + &json!({ + "risk_level": "high", + "user_authorization": "low", + "outcome": "deny", + "rationale": "The requested write has unacceptable test risk.", + }) + .to_string(), + ), + ev_completed("resp-guardian-denied"), + ]), + sse(vec![ + ev_response_created("resp-parent-after-denial"), + ev_assistant_message("msg-parent-after-denial", "denied"), + ev_completed("resp-parent-after-denial"), + ]), + ], + ) + .await; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "run a command that Guardian should deny".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + approval_policy: Some(approval_policy), + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + sandbox_policy: Some(sandbox_policy), + ..Default::default() + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = responses.requests(); + let guardian_request = requests + .iter() + .find(|request| request.body_contains_text("Exercise Guardian denial routing.")) + .expect("expected Guardian review request"); + assert!(guardian_request.body_contains_text(&command)); + + let tool_output = requests + .iter() + .find_map(|request| request.function_call_output_text("exec-call-denied")) + .expect("expected rejected tool output to be returned to the parent model"); + assert!( + tool_output.contains("The requested write has unacceptable test risk."), + "Guardian rationale missing from rejected tool output: {tool_output}" + ); + assert!( + !output_file.exists(), + "Guardian-denied command unexpectedly executed" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn guardian_review_session_does_not_inherit_legacy_notify() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 515d496b8b..196277394a 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -1,6 +1,7 @@ #![cfg(not(target_os = "windows"))] use anyhow::Ok; +use codex_features::Feature; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; @@ -1134,6 +1135,98 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sequential_cutoff_renders_done_summaries_for_active_reasoning_item() -> anyhow::Result<()> +{ + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let TestCodex { codex, .. } = test_codex() + .with_config(|config| { + config + .features + .enable(Feature::ConcurrentReasoningSummaries) + .expect("test config should allow feature update"); + }) + .build(&server) + .await?; + let summary_done = |item_id: &str, summary_index: i64, text: &str| { + serde_json::json!({ + "type": "response.reasoning_summary_text.done", + "item_id": item_id, + "summary_index": summary_index, + "text": text, + }) + }; + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_reasoning_item_added("reasoning-1", &[""]), + // Sequential cutoff renders atomic done events, not partial legacy events. + ev_reasoning_summary_text_delta("partial"), + summary_done("reasoning-1", 0, "step one"), + summary_done("reasoning-1", 1, "step two"), + ev_reasoning_item("reasoning-1", &["step one", "step two"], &[]), + ev_message_item_added("message-1", ""), + // Drop a stale completion rather than attaching it to the active message. + summary_done("reasoning-1", 2, "late step"), + ev_output_text_delta("Done"), + ev_assistant_message("message-1", "Done"), + ev_completed("resp-1"), + ]), + ) + .await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "reason through it".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + let reasoning_item = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ItemStarted(ItemStartedEvent { + item: TurnItem::Reasoning(item), + .. + }) => Some(item.clone()), + _ => None, + }) + .await; + + let mut summary_deltas = Vec::new(); + let mut summary_sections = Vec::new(); + loop { + match wait_for_event(&codex, |_| true).await { + EventMsg::ReasoningContentDelta(event) => { + summary_deltas.push((event.item_id, event.delta, event.summary_index)); + } + EventMsg::AgentReasoningSectionBreak(event) => { + summary_sections.push((event.item_id, event.summary_index)); + } + EventMsg::TurnComplete(_) => break, + _ => {} + } + } + + assert_eq!( + summary_deltas, + vec![ + (reasoning_item.id.clone(), "step one".to_string(), 0), + (reasoning_item.id, "step two".to_string(), 1), + ] + ); + assert_eq!(summary_sections, vec![("reasoning-1".to_string(), 1)]); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/mcp_auth_elicitation.rs b/codex-rs/core/tests/suite/mcp_auth_elicitation.rs new file mode 100644 index 0000000000..735006d495 --- /dev/null +++ b/codex-rs/core/tests/suite/mcp_auth_elicitation.rs @@ -0,0 +1,215 @@ +#![allow(clippy::unwrap_used)] + +use anyhow::Result; +use codex_core::config::Constrained; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_protocol::approvals::ElicitationRequest; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ElicitationAction; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::user_input::UserInput; +use core_test_support::PathExt; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; +use core_test_support::apps_test_server::search_capable_apps_builder; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call_with_namespace; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use wiremock::Mock; +use wiremock::Request; +use wiremock::Respond; +use wiremock::ResponseTemplate; +use wiremock::matchers::body_partial_json; +use wiremock::matchers::method; +use wiremock::matchers::path_regex; + +#[derive(Clone, Copy)] +struct AuthFailureResponder; + +impl Respond for AuthFailureResponder { + fn respond(&self, request: &Request) -> ResponseTemplate { + let body: Value = + serde_json::from_slice(&request.body).expect("tools/call request should be valid JSON"); + let id = body.get("id").cloned().unwrap_or(Value::Null); + + ResponseTemplate::new(/*status*/ 200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [{ + "type": "text", + "text": "Connector reauthentication required", + }], + "isError": true, + "_meta": { + "_codex_apps": { + "connector_auth_failure": { + "is_auth_failure": true, + "auth_reason": "reauthentication_required", + "connector_id": "calendar", + "link_id": "link_123", + "error_code": "UNAUTHORIZED", + "error_http_status_code": 401, + "error_action": "TRIGGER_REAUTHENTICATION", + }, + }, + }, + }, + })) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn codex_apps_auth_failure_requests_elicitation_by_default() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount_searchable(&server).await?; + Mock::given(method("POST")) + .and(path_regex("^/api/codex/apps/?$")) + .and(body_partial_json(json!({ + "method": "tools/call", + "params": { + "name": "calendar_create_event", + }, + }))) + .respond_with(AuthFailureResponder) + .with_priority(/*p*/ 1) + .mount(&server) + .await; + + let call_id = "calendar-auth-call"; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call_with_namespace( + call_id, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + &json!({ + "title": "Lunch", + "starts_at": "2026-06-18T12:00:00Z", + }) + .to_string(), + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = + search_capable_apps_builder(apps_server.chatgpt_base_url).with_config(|config| { + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + let user_config_path = config.codex_home.join("config.toml").abs(); + let user_config = toml::from_str( + r#" +[apps.calendar] +default_tools_approval_mode = "auto" +"#, + ) + .expect("apps config should parse"); + config.config_layer_stack = config + .config_layer_stack + .with_user_config(&user_config_path, user_config); + }); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "Use [$calendar](app://calendar) to create a calendar event.".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + let EventMsg::ElicitationRequest(request) = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ElicitationRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await + else { + panic!("default auth elicitation should prompt before completing the turn"); + }; + + assert_eq!(request.server_name, CODEX_APPS_MCP_SERVER_NAME); + assert_eq!( + request.id, + codex_protocol::mcp::RequestId::String(format!("codex_apps_auth_{call_id}")) + ); + assert_eq!( + request.request, + ElicitationRequest::Url { + meta: Some(json!({ + "_codex_apps": { + "connector_auth_failure": { + "is_auth_failure": true, + "connector_id": "calendar", + "connector_name": "Calendar", + "install_url": "https://chatgpt.com/apps/calendar/calendar", + "auth_reason": "reauthentication_required", + "link_id": "link_123", + "error_code": "UNAUTHORIZED", + "error_http_status_code": 401, + "error_action": "TRIGGER_REAUTHENTICATION", + }, + }, + })), + message: "Reconnect Calendar on ChatGPT to restore access for this request." + .to_string(), + url: "https://chatgpt.com/apps/calendar/calendar".to_string(), + elicitation_id: format!("codex_apps_auth_{call_id}"), + } + ); + + test.codex + .submit(Op::ResolveElicitation { + server_name: request.server_name, + request_id: request.id, + decision: ElicitationAction::Accept, + content: None, + meta: None, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output = requests[1] + .function_call_output_text(call_id) + .expect("follow-up request should contain the auth elicitation result"); + assert!(output.contains( + "Authentication for Calendar was requested and accepted. Retry this tool call now." + )); + assert!(!output.contains("Connector reauthentication required")); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mcp_tool_exposure.rs b/codex-rs/core/tests/suite/mcp_tool_exposure.rs index 32ecf7cf2a..51ab7842ee 100644 --- a/codex-rs/core/tests/suite/mcp_tool_exposure.rs +++ b/codex-rs/core/tests/suite/mcp_tool_exposure.rs @@ -1,22 +1,32 @@ use anyhow::Result; use codex_features::Feature; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; +use codex_protocol::request_user_input::RequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_protocol::user_input::UserInput; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; +use core_test_support::apps_test_server::apps_enabled_builder; use core_test_support::apps_test_server::search_capable_apps_builder; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::namespace_child_tool; use core_test_support::responses::sse; use core_test_support::skip_if_no_network; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; use core_test_support::wait_for_mcp_server; use serde_json::Value; +use serde_json::json; +use std::collections::HashMap; use std::time::Duration; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -86,6 +96,136 @@ async fn code_mode_only_exposes_direct_model_only_mcp_namespaces() -> Result<()> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apps_guidance_appears_after_background_recovery_within_a_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (apps_server, startup_control) = + AppsTestServer::mount_with_startup_control(&server).await?; + // Initial context is rendered twice before the first request, so keep both reads unavailable. + startup_control.fail_next_initialize_attempts(/*attempts*/ 2); + let call_id = "pause-for-apps"; + let response = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + "request_user_input", + &json!({ + "questions": [{ + "id": "continue", + "header": "Continue", + "question": "Continue after Apps recovers?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the test." + }, { + "label": "No", + "description": "Stop the test." + }] + }] + }) + .to_string(), + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-2", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let mut builder = + apps_enabled_builder(apps_server.chatgpt_base_url.clone()).with_config(|config| { + config + .features + .enable(Feature::DeferredExecutor) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::DefaultModeRequestUserInput) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + let mcp_runtime = test.codex.current_mcp_runtime().await; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "use an app after it recovers".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + let request = wait_for_event_match(&test.codex, |event| match event { + EventMsg::RequestUserInput(request) => Some(request.clone()), + _ => None, + }) + .await; + + let initial_requests = response.requests(); + assert_eq!(initial_requests.len(), 1); + let initial_request = &initial_requests[0]; + assert_eq!( + initial_request + .message_input_texts("developer") + .iter() + .filter(|text| text.contains("")) + .count(), + 0 + ); + + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if !mcp_runtime.manager().list_all_tools().await.is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("Apps MCP should recover while the turn is paused"); + test.codex + .submit(Op::UserInputAnswer { + id: request.turn_id, + response: RequestUserInputResponse { + answers: HashMap::from([( + "continue".to_string(), + RequestUserInputAnswer { + answers: vec!["Yes (Recommended)".to_string()], + }, + )]), + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = response.requests(); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[1] + .message_input_texts("developer") + .iter() + .filter(|text| text.contains("")) + .count(), + 1 + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn later_follow_up_uses_background_recovered_apps_after_mid_thread_startup_failures() -> Result<()> { diff --git a/codex-rs/core/tests/suite/mcp_turn_metadata.rs b/codex-rs/core/tests/suite/mcp_turn_metadata.rs index 0d403180d7..0ecaa80274 100644 --- a/codex-rs/core/tests/suite/mcp_turn_metadata.rs +++ b/codex-rs/core/tests/suite/mcp_turn_metadata.rs @@ -20,6 +20,7 @@ use codex_protocol::user_input::UserInput; use core_test_support::PathExt; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_LIST_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; use core_test_support::apps_test_server::recorded_apps_tool_call_by_call_id; use core_test_support::apps_test_server::search_capable_apps_builder; @@ -45,6 +46,7 @@ fn set_calendar_approval_mode(config: &mut Config, approval_mode: AppToolApprova let approval_mode = match approval_mode { AppToolApproval::Auto => "auto", AppToolApproval::Prompt => "prompt", + AppToolApproval::Writes => "writes", AppToolApproval::Approve => "approve", }; let user_config_path = config.codex_home.join("config.toml").abs(); @@ -68,6 +70,7 @@ fn set_default_app_approval_mode_and_reviewer( let approval_mode = match approval_mode { AppToolApproval::Auto => "auto", AppToolApproval::Prompt => "prompt", + AppToolApproval::Writes => "writes", AppToolApproval::Approve => "approve", }; let user_config_path = config.codex_home.join("config.toml").abs(); @@ -333,6 +336,127 @@ async fn apps_default_prompt_with_auto_review_routes_actual_mcp_approval_to_guar Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apps_default_writes_prompts_for_writes_but_not_reads() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let read_call_id = "calendar-read"; + let write_call_id = "calendar-write"; + let list_args = serde_json::to_string(&json!({}))?; + let create_args = serde_json::to_string(&json!({ + "title": "Lunch", + "starts_at": "2026-03-10T12:00:00Z" + }))?; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-read"), + ev_function_call_with_namespace( + read_call_id, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_LIST_TOOL, + &list_args, + ), + ev_completed("resp-read"), + ]), + sse(vec![ + ev_response_created("resp-write"), + ev_function_call_with_namespace( + write_call_id, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + &create_args, + ), + ev_completed("resp-write"), + ]), + sse(vec![ + ev_response_created("resp-done"), + ev_assistant_message("msg-done", "done"), + ev_completed("resp-done"), + ]), + ], + ) + .await; + + let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone()) + .with_config(|config| { + config + .features + .enable(Feature::ToolCallMcpElicitation) + .expect("test config should allow feature update"); + set_default_app_approval_mode_and_reviewer( + config, + AppToolApproval::Writes, + ApprovalsReviewer::User, + ); + }); + let test = builder.build(&server).await?; + + submit_user_turn( + &test, + "Use [$calendar](app://calendar) to list events, then create one.", + AskForApproval::OnRequest, + /*collaboration_mode*/ None, + ) + .await?; + + let EventMsg::McpToolCallBegin(read_begin) = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::McpToolCallBegin(_)) + }) + .await + else { + unreachable!("event guard guarantees McpToolCallBegin"); + }; + assert_eq!(read_begin.call_id, read_call_id); + + let next_route = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::McpToolCallBegin(_) | EventMsg::ElicitationRequest(_) + ) + }) + .await; + let EventMsg::McpToolCallBegin(write_begin) = next_route else { + panic!("read-only app action should not prompt in writes mode"); + }; + assert_eq!(write_begin.call_id, write_call_id); + + let EventMsg::ElicitationRequest(request) = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ElicitationRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await + else { + panic!("write app action should prompt in writes mode"); + }; + + test.codex + .submit(Op::ResolveElicitation { + server_name: request.server_name, + request_id: request.id, + decision: ElicitationAction::Accept, + content: None, + meta: None, + }) + .await?; + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert_eq!(responses.requests().len(), 3); + recorded_apps_tool_call_by_call_id(&server, read_call_id).await; + recorded_apps_tool_call_by_call_id(&server, write_call_id).await; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_tool_call_metadata_records_prior_request_user_input_tool() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 3e27463370..e8bb48856c 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -54,6 +54,7 @@ mod exec; mod exec_policy; #[cfg(not(target_os = "windows"))] mod extension_sandbox; +mod external_auth; mod fork_thread; #[cfg(not(target_os = "windows"))] mod guardian_review; @@ -65,6 +66,7 @@ mod image_rollout; mod items; mod json_result; mod live_cli; +mod mcp_auth_elicitation; #[cfg(unix)] mod mcp_refresh_cleanup; mod mcp_tool_exposure; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b08ca7cc8a..9ef3675b3e 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -387,6 +387,46 @@ async fn unsupported_service_tier_is_omitted_from_http_turn() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unsupported_configured_service_tier_warns_at_session_start() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let model_slug = "test-no-tier-model"; + let model = test_model_info( + model_slug, + model_slug, + "no service tiers", + default_input_modalities(), + ); + let mut builder = test_codex() + .with_model(model_slug) + .with_config(move |config| { + config.service_tier = Some(ServiceTier::Flex.request_value().to_string()); + config.model_catalog = Some(ModelsResponse { + models: vec![model], + }); + }); + let test = builder.build(&server).await?; + + let warning = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::Warning(warning) + if warning.message.contains("will be omitted from requests") + ) + }) + .await; + let EventMsg::Warning(warning) = warning else { + unreachable!("wait_for_event matched a warning") + }; + assert_eq!( + warning.message, + "Configured service tier `flex` is not advertised as supported for model `test-no-tier-model` and will be omitted from requests." + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn default_service_tier_override_is_omitted_from_http_turn() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/network_approval.rs b/codex-rs/core/tests/suite/network_approval.rs index 6b96c0d635..437155f325 100644 --- a/codex-rs/core/tests/suite/network_approval.rs +++ b/codex-rs/core/tests/suite/network_approval.rs @@ -28,6 +28,7 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; @@ -56,6 +57,181 @@ use tempfile::TempDir; const NETWORK_TEST_HOST: &str = "codex-network-test.invalid"; const NETWORK_TEST_TARGET: &str = "http://codex-network-test.invalid:80"; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[cfg_attr( + not(target_os = "linux"), + ignore = "requires the trusted Linux proxy bridge" +)] +async fn guardian_receives_exact_triggers_for_concurrent_network_requests() -> Result<()> { + skip_if_target_windows!(Ok(()), "uses the POSIX/Python network fixture"); + skip_if_host_windows!(Ok(())); + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let test = managed_network_unified_exec_test(&server).await?; + let barrier_dir = TempDir::new_in(test.cwd.path())?; + let first_marker = barrier_dir.path().join("first"); + let second_marker = barrier_dir.path().join("second"); + let network_command = |marker: &PathBuf, peer_marker: &PathBuf, host: &str| { + format!( + "touch '{}' && while [ ! -e '{}' ]; do sleep 0.01; done && python3 -c \"import urllib.request; urllib.request.build_opener(urllib.request.ProxyHandler()).open('http://{host}', timeout=10).read()\"", + marker.display(), + peer_marker.display(), + ) + }; + let first_command = network_command(&first_marker, &second_marker, "1.1.1.1"); + let second_command = network_command(&second_marker, &first_marker, "8.8.8.8"); + mount_sse_once_match( + &server, + |request: &wiremock::Request| { + !is_guardian_request(request) + && request_body_contains(request, "run both network requests") + && !request_body_contains(request, "exec-network-first") + }, + sse(vec![ + ev_response_created("resp-network-concurrent"), + ev_function_call( + "exec-network-first", + "exec_command", + &serde_json::to_string(&network_exec_args(&first_command))?, + ), + ev_function_call( + "exec-network-second", + "exec_command", + &serde_json::to_string(&network_exec_args(&second_command))?, + ), + ev_completed("resp-network-concurrent"), + ]), + ) + .await; + let first_guardian = mount_sse_once_match( + &server, + |request: &wiremock::Request| guardian_request_is_for(request, "exec-network-first"), + sse(vec![ + ev_response_created("resp-network-guardian-1"), + ev_assistant_message("msg-network-guardian-1", r#"{"outcome":"deny"}"#), + ev_completed("resp-network-guardian-1"), + ]), + ) + .await; + let second_guardian = mount_sse_once_match( + &server, + |request: &wiremock::Request| guardian_request_is_for(request, "exec-network-second"), + sse(vec![ + ev_response_created("resp-network-guardian-2"), + ev_assistant_message("msg-network-guardian-2", r#"{"outcome":"deny"}"#), + ev_completed("resp-network-guardian-2"), + ]), + ) + .await; + mount_sse_once_match( + &server, + |request: &wiremock::Request| { + !is_guardian_request(request) + && request_body_contains(request, "exec-network-first") + && request_body_contains(request, "exec-network-second") + }, + sse(vec![ + ev_response_created("resp-network-done"), + ev_assistant_message("msg-network-done", "done"), + ev_completed("resp-network-done"), + ]), + ) + .await; + + submit_managed_network_turn( + &test, + "run both network requests", + vec![local(test.config.cwd.clone())], + ApprovalsReviewer::AutoReview, + AskForApproval::OnRequest, + ) + .await?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let actual_triggers = loop { + let mut actual_triggers = guardian_network_triggers(&[&first_guardian, &second_guardian])?; + actual_triggers.sort_unstable(); + actual_triggers.dedup(); + if actual_triggers.len() == 2 { + break actual_triggers; + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!("timed out waiting for both Guardian network reviews"); + } + tokio::time::sleep(Duration::from_millis(10)).await; + }; + wait_for_turn_complete(&test).await; + + assert_eq!( + actual_triggers, + vec![ + ("exec-network-first".to_string(), first_command), + ("exec-network-second".to_string(), second_command), + ] + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[cfg_attr( + not(target_os = "linux"), + ignore = "requires the trusted Linux proxy bridge" +)] +async fn guardian_receives_exact_trigger_for_single_network_request() -> Result<()> { + skip_if_target_windows!(Ok(()), "uses the POSIX/Python network fixture"); + skip_if_host_windows!(Ok(())); + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let test = managed_network_unified_exec_test(&server).await?; + let command = "python3 -c \"import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://1.1.1.1', timeout=10).read().decode(errors='replace'))\"".to_string(); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-network-single"), + ev_function_call( + "exec-network-single", + "exec_command", + &serde_json::to_string(&network_exec_args(&command))?, + ), + ev_completed("resp-network-single"), + ]), + sse(vec![ + ev_response_created("resp-network-guardian"), + ev_assistant_message("msg-network-guardian", r#"{"outcome":"deny"}"#), + ev_completed("resp-network-guardian"), + ]), + sse(vec![ + ev_response_created("resp-network-done"), + ev_assistant_message("msg-network-done", "done"), + ev_completed("resp-network-done"), + ]), + ], + ) + .await; + + submit_managed_network_turn( + &test, + "run one network request", + vec![local(test.config.cwd.clone())], + ApprovalsReviewer::AutoReview, + AskForApproval::OnRequest, + ) + .await?; + wait_for_turn_complete(&test).await; + + assert_eq!( + guardian_network_triggers(&[&responses])?, + vec![("exec-network-single".to_string(), command)] + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn approved_network_host_for_one_environment_still_prompts_in_another() -> Result<()> { skip_if_target_windows!(Ok(()), "uses the POSIX/Python network fixture"); @@ -99,6 +275,8 @@ async fn approved_network_host_for_one_environment_still_prompts_in_another() -> &test, "fetch from the local environment", environments.clone(), + ApprovalsReviewer::User, + AskForApproval::UnlessTrusted, ) .await?; let approval = expect_network_approval(&test, LOCAL_ENVIRONMENT_ID).await?; @@ -122,6 +300,8 @@ async fn approved_network_host_for_one_environment_still_prompts_in_another() -> &test, "fetch from the remote environment", environments.clone(), + ApprovalsReviewer::User, + AskForApproval::UnlessTrusted, ) .await?; let approval = expect_network_approval(&test, REMOTE_ENVIRONMENT_ID).await?; @@ -225,12 +405,20 @@ async fn mount_exec_network_turn( } fn network_fetch_args(environment_id: &str) -> Value { + let command = format!( + "python3 -c \"import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://{NETWORK_TEST_HOST}', timeout=2).read().decode(errors='replace'))\"" + ); + let mut args = network_exec_args(&command); + args["environment_id"] = json!(environment_id); + args +} + +fn network_exec_args(command: &str) -> Value { json!({ "shell": "/bin/sh", - "cmd": format!("python3 -c \"import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://{NETWORK_TEST_HOST}', timeout=2).read().decode(errors='replace'))\""), + "cmd": command, "login": false, "yield_time_ms": 1_000, - "environment_id": environment_id, }) } @@ -238,6 +426,8 @@ async fn submit_managed_network_turn( test: &TestCodex, prompt: &str, environments: Vec, + approvals_reviewer: ApprovalsReviewer, + approval_policy: AskForApproval, ) -> Result<()> { let permission_profile = PermissionProfile::workspace_write_with( &[], @@ -261,8 +451,8 @@ async fn submit_managed_network_turn( additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { environments: Some(turn_environment_selections), - approval_policy: Some(AskForApproval::OnRequest), - approvals_reviewer: Some(ApprovalsReviewer::User), + approval_policy: Some(approval_policy), + approvals_reviewer: Some(approvals_reviewer), sandbox_policy: Some(sandbox_policy), permission_profile, collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { @@ -281,6 +471,93 @@ async fn submit_managed_network_turn( Ok(()) } +fn decoded_request_body(request: &wiremock::Request) -> Option> { + let is_zstd = request + .headers + .get("content-encoding") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .any(|entry| entry.trim().eq_ignore_ascii_case("zstd")) + }); + if is_zstd { + zstd::stream::decode_all(std::io::Cursor::new(&request.body)).ok() + } else { + Some(request.body.clone()) + } +} + +fn request_body_contains(request: &wiremock::Request, text: &str) -> bool { + decoded_request_body(request) + .and_then(|body| String::from_utf8(body).ok()) + .is_some_and(|body| body.contains(text)) +} + +fn is_guardian_request(request: &wiremock::Request) -> bool { + decoded_request_body(request) + .and_then(|body| serde_json::from_slice::(&body).ok()) + .is_some_and(|body| { + body.pointer("/client_metadata/x-openai-subagent") + .and_then(Value::as_str) + == Some("guardian") + }) +} + +fn guardian_request_is_for(request: &wiremock::Request, call_id: &str) -> bool { + decoded_request_body(request) + .and_then(|body| serde_json::from_slice::(&body).ok()) + .filter(|body| { + body.pointer("/client_metadata/x-openai-subagent") + .and_then(Value::as_str) + == Some("guardian") + }) + .and_then(|body| { + body.get("input") + .and_then(Value::as_array) + .and_then(|input| { + input + .iter() + .rev() + .find(|item| item.get("role").and_then(Value::as_str) == Some("user")) + }) + .cloned() + }) + .is_some_and(|latest_user_message| latest_user_message.to_string().contains(call_id)) +} + +fn guardian_network_triggers(responses: &[&ResponseMock]) -> Result> { + responses + .iter() + .flat_map(|responses| responses.requests()) + .filter(|request| { + request.body_json()["client_metadata"]["x-openai-subagent"].as_str() == Some("guardian") + }) + .map(|request| { + let user_texts = request.message_input_texts("user"); + let action: Value = serde_json::from_str( + user_texts + .iter() + .find(|text| text.contains("\"tool\": \"network_access\"")) + .context("expected network access JSON in Guardian request")? + .trim(), + )?; + Ok(( + action + .pointer("/trigger/callId") + .and_then(Value::as_str) + .context("expected exact trigger call id")? + .to_string(), + action + .pointer("/trigger/command/2") + .and_then(Value::as_str) + .context("expected exact trigger command")? + .to_string(), + )) + }) + .collect() +} + async fn expect_network_approval( test: &TestCodex, expected_environment_id: &str, diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index e5a69962bc..7f42ba893c 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -2,10 +2,15 @@ use anyhow::Result; use codex_config::ConfigLayerStack; use codex_core::ForkSnapshot; use codex_core::config::Constrained; +use codex_core::context::ApprovalPromptContext; use codex_core::context::ContextualUserFragment; use codex_core::context::PermissionsInstructions; use codex_core::load_exec_policy; +use codex_models_manager::model_info::model_info_from_slug; use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ApprovalMessages; +use codex_protocol::openai_models::ModelMessages; +use codex_protocol::openai_models::ModelsResponse; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -33,6 +38,129 @@ fn permissions_texts(request: &ResponsesRequest) -> Vec { .collect() } +fn model_with_approval_messages( + slug: &str, + on_request: &str, + on_request_auto_review: &str, +) -> codex_protocol::openai_models::ModelInfo { + let mut model = model_info_from_slug(slug); + model.model_messages = Some(ModelMessages { + instructions_template: None, + instructions_variables: None, + approvals: Some(ApprovalMessages { + on_request: Some(on_request.to_string()), + on_request_auto_review: Some(on_request_auto_review.to_string()), + }), + }); + model +} + +async fn submit_text_turn( + test: &core_test_support::test_codex::TestCodex, + text: &str, +) -> Result<()> { + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn catalog_approval_message_is_sent_in_initial_permissions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let model_slug = "catalog-approvals-model"; + let model = model_with_approval_messages( + model_slug, + "catalog user approval instructions", + "catalog auto-review approval instructions", + ); + let mut builder = test_codex() + .with_model(model_slug) + .with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.model_catalog = Some(ModelsResponse { + models: vec![model], + }); + }); + let test = builder.build(&server).await?; + + submit_text_turn(&test, "hello").await?; + + let permissions = permissions_texts(&req.single_request()); + assert_eq!(permissions.len(), 1); + assert!(permissions[0].contains("catalog user approval instructions")); + assert!(permissions[0].contains("Filesystem sandboxing defines")); + assert!(!permissions[0].contains("How to request escalation")); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn model_change_appends_new_catalog_approval_message() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let _req1 = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let req2 = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ) + .await; + let first_slug = "catalog-approvals-model-a"; + let second_slug = "catalog-approvals-model-b"; + let first = model_with_approval_messages(first_slug, "model A approvals", "model A auto"); + let second = model_with_approval_messages(second_slug, "model B approvals", "model B auto"); + let mut builder = test_codex() + .with_model(first_slug) + .with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.model_catalog = Some(ModelsResponse { + models: vec![first, second], + }); + }); + let test = builder.build(&server).await?; + submit_text_turn(&test, "first").await?; + + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { + model: Some(second_slug.to_string()), + ..Default::default() + }, + ) + .await?; + submit_text_turn(&test, "second").await?; + + let permissions = permissions_texts(&req2.single_request()); + assert_eq!(permissions.len(), 2); + assert!( + permissions + .last() + .is_some_and(|text| text.contains("model B approvals")) + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn permissions_message_sent_once_on_start() -> Result<()> { skip_if_no_network!(Ok(())); @@ -563,7 +691,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { let expected = PermissionsInstructions::from_permission_profile( &permission_profile, AskForApproval::OnRequest, - test.config.approvals_reviewer, + ApprovalPromptContext::new(test.config.approvals_reviewer, /*messages*/ None), &exec_policy, test.config.cwd.as_path(), /*exec_permission_approvals_enabled*/ false, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index e74695b59d..88733b4666 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -573,6 +573,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow personality_friendly: Some(friendly_personality_message.to_string()), personality_pragmatic: Some("Pragmatic variant".to_string()), }), + approvals: None, }), include_skills_usage_instructions: false, supports_reasoning_summaries: false, @@ -689,6 +690,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - personality_friendly: Some(remote_friendly_message.to_string()), personality_pragmatic: Some(remote_pragmatic_message.to_string()), }), + approvals: None, }), include_skills_usage_instructions: false, supports_reasoning_summaries: false, diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index b3420feabe..aaf65baad0 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -255,8 +255,8 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() .find("## Plugins") .expect("expected plugins section in developer message"); assert!( - apps_pos < skills_pos && skills_pos < plugins_pos, - "expected Apps -> Skills -> Plugins order: {developer_messages:?}" + skills_pos < apps_pos && apps_pos < plugins_pos, + "expected Skills -> Apps -> Plugins order: {developer_messages:?}" ); assert!( !developer_text.contains("`sample`: inspect sample data"), diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 1a95223bff..f4262e8e40 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -285,6 +285,7 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -431,6 +432,7 @@ async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -526,6 +528,7 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -713,6 +716,7 @@ async fn conversation_webrtc_start_uses_avas_query() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -811,6 +815,7 @@ async fn conversation_webrtc_default_v1_ignores_configured_v2_voice() -> Result< test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -871,6 +876,7 @@ async fn conversation_webrtc_default_v1_rejects_explicit_v2_voice() -> Result<() test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -941,6 +947,7 @@ async fn conversation_webrtc_start_uses_configured_call_base_url_for_avas() -> R test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1035,6 +1042,7 @@ async fn conversation_webrtc_close_while_sideband_connecting_drops_pending_join( test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1126,6 +1134,7 @@ async fn conversation_webrtc_sideband_connect_failure_closes_with_error() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1219,6 +1228,7 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1271,27 +1281,46 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() -> Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn conversation_transport_close_emits_closed_event() -> Result<()> { +async fn assert_transport_close_tail_flush( + flush_transcript_tail_on_session_end: bool, +) -> Result<()> { skip_if_no_network!(Ok(())); - let session_updated = vec![json!({ - "type": "session.updated", - "session": { "id": "sess_1", "instructions": "backend prompt" } - })]; - let server = start_websocket_server(vec![vec![], vec![session_updated]]).await; + let api_server = start_mock_server().await; + let response_mock = responses::mount_sse_once( + &api_server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "ok"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let realtime_server = start_websocket_server(vec![vec![vec![ + json!({ + "type": "session.updated", + "session": { "id": "sess_1", "instructions": "backend prompt" } + }), + json!({ + "type": "conversation.input_transcript.delta", + "delta": "transport tail" + }), + ]]]) + .await; - let mut builder = test_codex(); - let test = builder.build_with_websocket_server(&server).await?; - assert!( - server - .wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2)) - .await - ); + let mut builder = test_codex().with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; + } + }); + let test = builder.build(&api_server).await?; test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1334,8 +1363,27 @@ async fn conversation_transport_close_emits_closed_event() -> Result<()> { }) .await; assert_eq!(closed.reason.as_deref(), Some("transport_closed")); + if flush_transcript_tail_on_session_end { + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + while response_mock.requests().is_empty() { + assert!(tokio::time::Instant::now() < deadline); + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(response_mock.single_request().message_input_texts("user").iter().any(|text| text + == "\n The user just ended their realtime session. Here is the remaining handoff/transcript tail. You probably do not have to do anything; acknowledge the handoff unless the transcript itself asks for something.\n user: transport tail\n")); + } else { + tokio::time::sleep(Duration::from_millis(200)).await; + assert!(response_mock.requests().is_empty()); + } - server.shutdown().await; + realtime_server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_transport_close_tail_flush_is_opt_in() -> Result<()> { + assert_transport_close_tail_flush(/*flush_transcript_tail_on_session_end*/ false).await?; + assert_transport_close_tail_flush(/*flush_transcript_tail_on_session_end*/ true).await?; Ok(()) } @@ -1389,6 +1437,7 @@ async fn conversation_start_preflight_failure_emits_realtime_error_only() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1440,6 +1489,7 @@ async fn conversation_start_connect_failure_emits_realtime_error_only() -> Resul test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1539,6 +1589,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1569,6 +1620,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1670,6 +1722,7 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1739,6 +1792,7 @@ async fn conversation_uses_default_realtime_backend_prompt() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1816,6 +1870,7 @@ async fn conversation_uses_empty_instructions_for_null_or_empty_prompt() -> Resu test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1886,6 +1941,7 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1948,6 +2004,7 @@ async fn conversation_uses_configured_realtime_voice() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1998,6 +2055,7 @@ async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2049,6 +2107,7 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2126,6 +2185,7 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() - test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2197,6 +2257,7 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2261,6 +2322,7 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2380,6 +2442,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2492,6 +2555,7 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<() test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2556,6 +2620,7 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2641,6 +2706,7 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2742,6 +2808,7 @@ async fn realtime_v2_noop_tool_call_returns_empty_function_output_without_respon test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2845,6 +2912,7 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2984,6 +3052,7 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: Some(SILENT_CONTEXT_PREFIX.to_string()), @@ -3140,6 +3209,7 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -3254,6 +3324,7 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -3361,6 +3432,7 @@ async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -3426,6 +3498,115 @@ async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() -> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_close_routes_only_remaining_transcript_tail_once() -> Result<()> { + skip_if_no_network!(Ok(())); + + let api_server = start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &api_server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "first ok"), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "tail ok"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + let realtime_server = start_websocket_server(vec![vec![ + vec![ + json!({ + "type": "session.updated", + "session": { "id": "sess_tail", "instructions": "backend prompt" } + }), + json!({ + "type": "conversation.input_transcript.delta", + "delta": "already handed off" + }), + json!({ + "type": "conversation.handoff.requested", + "handoff_id": "handoff_tail", + "item_id": "item_tail", + "input_transcript": "already handed off" + }), + json!({ + "type": "conversation.output_transcript.delta", + "delta": "remaining answer" + }), + json!({ + "type": "conversation.input_transcript.delta", + "delta": "remaining question" + }), + ], + vec![], + ]]) + .await; + let mut builder = test_codex().with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; + } + }); + let test = builder.build(&api_server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + client_managed_handoffs: false, + flush_transcript_tail_on_session_end: true, + codex_responses_as_items: false, + codex_response_item_prefix: None, + codex_response_handoff_prefix: None, + model: None, + output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, + prompt: Some(Some("backend prompt".to_string())), + realtime_session_id: None, + transport: None, + version: None, + voice: None, + })) + .await?; + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.codex.submit(Op::RealtimeConversationClose).await?; + + let closed = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()), + _ => None, + }) + .await; + assert_eq!(closed.reason.as_deref(), Some("requested")); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + while response_mock.requests().len() < 2 { + assert!(tokio::time::Instant::now() < deadline); + tokio::time::sleep(Duration::from_millis(10)).await; + } + + test.codex.submit(Op::RealtimeConversationClose).await?; + tokio::time::sleep(Duration::from_millis(200)).await; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + assert!(requests[0].message_input_texts("user").iter().any(|text| text + == "\n already handed off\n user: already handed off\n")); + assert!(requests[1].message_input_texts("user").iter().any(|text| text + == "\n The user just ended their realtime session. Here is the remaining handoff/transcript tail. You probably do not have to do anything; acknowledge the handoff unless the transcript itself asks for something.\n assistant: remaining answer\nuser: remaining question\n")); + + realtime_server.shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio() -> Result<()> { skip_if_no_network!(Ok(())); @@ -3466,6 +3647,7 @@ async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio( test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -3593,6 +3775,7 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -3750,6 +3933,7 @@ async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> R test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -3896,6 +4080,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -4053,6 +4238,7 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio() test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index af44baf6ca..e403b7e1ff 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -29,6 +29,7 @@ use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use tempfile::TempDir; use tokio::io::AsyncWriteExt as _; use uuid::Uuid; @@ -200,6 +201,95 @@ async fn review_op_emits_lifecycle_and_review_output() { server.verify().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cancelled_review_does_not_forward_delegate_mcp_startup() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let request_log = responses::mount_response_once( + &server, + responses::sse_response(responses::sse(vec![responses::ev_response_created( + "resp-1", + )])) + .set_delay(Duration::from_secs(30)), + ) + .await; + let codex_home = Arc::new(TempDir::new().unwrap()); + let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; + + // Consume the parent session's own empty startup round before starting the review. + wait_for_event(&codex, |event| { + matches!(event, EventMsg::McpStartupComplete(_)) + }) + .await; + + codex + .submit(Op::Review { + review_request: ReviewRequest { + target: ReviewTarget::Custom { + instructions: "Cancel this review".to_string(), + }, + user_facing_hint: None, + }, + }) + .await + .unwrap(); + + tokio::time::timeout(Duration::from_secs(5), async { + loop { + match codex.next_event().await.expect("review event").msg { + event @ (EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_)) => { + panic!("review forwarded delegate MCP startup: {event:?}") + } + EventMsg::EnteredReviewMode(_) => break, + _ => {} + } + } + }) + .await + .expect("timed out waiting for review entry"); + + tokio::time::timeout(Duration::from_secs(5), async { + while request_log.requests().is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .expect("review request did not reach the server"); + + codex.submit(Op::Interrupt).await.unwrap(); + + let mut exited_review = false; + tokio::time::timeout(Duration::from_secs(5), async { + loop { + match codex + .next_event() + .await + .expect("review cancellation event") + .msg + { + event @ (EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_)) => { + panic!("cancelled review forwarded delegate MCP startup: {event:?}") + } + EventMsg::ExitedReviewMode(ExitedReviewModeEvent { review_output }) => { + assert_eq!(review_output, None); + exited_review = true; + } + EventMsg::TurnAborted(_) if exited_review => break, + EventMsg::TurnAborted(_) => panic!("review turn aborted before review mode exited"), + _ => {} + } + } + }) + .await + .expect("timed out waiting for review cancellation"); + + assert_eq!(request_log.requests().len(), 1); + + let _codex_home_guard = codex_home; + server.verify().await; +} + /// When the model returns plain text that is not JSON, ensure the child /// lifecycle still occurs and the plain text is surfaced via /// ExitedReviewMode(Some(..)) as the overall_explanation. diff --git a/codex-rs/core/tests/suite/rollout_budget.rs b/codex-rs/core/tests/suite/rollout_budget.rs index 9485523659..ba199653e4 100644 --- a/codex-rs/core/tests/suite/rollout_budget.rs +++ b/codex-rs/core/tests/suite/rollout_budget.rs @@ -195,9 +195,21 @@ async fn subagent_usage_draws_from_the_shared_budget() -> Result<()> { .await; test.submit_turn(FOLLOW_UP_PROMPT).await?; - let request = follow_up.single_request(); + let requests = follow_up + .requests() + .into_iter() + .filter(|request| { + request + .message_input_texts("user") + .iter() + .any(|text| text == FOLLOW_UP_PROMPT) + }) + .collect::>(); + let [request] = requests.as_slice() else { + anyhow::bail!("expected 1 follow-up request, got {}", requests.len()); + }; assert_eq!( - rollout_budget_texts(&request).last(), + rollout_budget_texts(request).last(), Some(&rollout_budget_message(/*remaining_tokens*/ 50)) ); diff --git a/codex-rs/core/tests/suite/safety_buffering.rs b/codex-rs/core/tests/suite/safety_buffering.rs index 5a8393f864..473cc18ced 100644 --- a/codex-rs/core/tests/suite/safety_buffering.rs +++ b/codex-rs/core/tests/suite/safety_buffering.rs @@ -83,7 +83,7 @@ async fn emits_safety_buffering_with_the_responses_api_model_without_header_gati created["safety_buffering"] = json!({ "use_cases": ["cyber"], "reasons": ["policy-check"], - "faster_model": FASTER_MODEL, + "retry_model": FASTER_MODEL, }); mount_response_once( &server, diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 7424d3f360..fdaed25623 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -1120,10 +1120,22 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result test.submit_turn(TURN_1_PROMPT).await?; - let child_request = wait_for_requests(&child_request_log) - .await? - .pop() - .expect("child request"); + // The response mock records candidate requests before its request matcher runs, so wait for + // the child request instead of assuming the latest recorded request is already it. + let deadline = Instant::now() + Duration::from_secs(2); + let child_request = loop { + if let Some(request) = child_request_log + .requests() + .into_iter() + .find(|request| !request.inputs_of_type("agent_message").is_empty()) + { + break request; + } + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for child agent message request"); + } + sleep(Duration::from_millis(10)).await; + }; assert_eq!( strip_metadata_from_json(Value::Array(child_request.inputs_of_type("agent_message"))), Value::Array(vec![json!({ diff --git a/codex-rs/core/tests/suite/web_search.rs b/codex-rs/core/tests/suite/web_search.rs index 5728b97a74..178789eff6 100644 --- a/codex-rs/core/tests/suite/web_search.rs +++ b/codex-rs/core/tests/suite/web_search.rs @@ -276,7 +276,7 @@ location = { country = "US", city = "New York", timezone = "America/New_York" } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn indexed_web_search_mode_sets_index_gate() { +async fn indexed_web_search_mode_sets_indexed_access() { skip_if_no_network!(); let server = start_mock_server().await; @@ -308,7 +308,7 @@ async fn indexed_web_search_mode_sets_index_gate() { assert_eq!( ( tool.get("external_web_access").and_then(Value::as_bool), - tool.get("index_gated_web_access").and_then(Value::as_bool), + tool.get("indexed_web_access").and_then(Value::as_bool), ), (Some(true), Some(true)) ); diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 35fa411021..f303ac70ee 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -17,7 +17,7 @@ base64 = { workspace = true } bytes = { workspace = true } clatter = { workspace = true } codex-api = { workspace = true } -codex-client = { workspace = true } +codex-http-client = { workspace = true } codex-exec-server-protocol = { workspace = true } codex-file-system = { workspace = true } codex-network-proxy = { workspace = true } @@ -74,6 +74,7 @@ rustls = { workspace = true } serial_test = { workspace = true } tempfile = { workspace = true } test-case = "3.3.1" +tokio = { workspace = true, features = ["test-util"] } tracing-opentelemetry = { workspace = true } tracing-subscriber = { workspace = true } wiremock = { workspace = true } diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 82412a58e6..5590fd3b5e 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -1394,7 +1394,16 @@ mod tests { assert_eq!(session.process_id(), &process_id); let trace = server.await.expect("server task").expect("trace context"); - assert_eq!(trace, expected_trace); + let expected_traceparent = expected_trace + .traceparent + .as_deref() + .expect("parent traceparent"); + let traceparent = trace.traceparent.as_deref().expect("request traceparent"); + let expected_parts = expected_traceparent.split('-').collect::>(); + let parts = traceparent.split('-').collect::>(); + assert_eq!(parts[1], expected_parts[1]); + assert_ne!(parts[2], expected_parts[2]); + assert_eq!(trace.tracestate, expected_trace.tracestate); } async fn accept_websocket(listener: &TcpListener) -> WebSocketStream { diff --git a/codex-rs/exec-server/src/client/reqwest_http_client.rs b/codex-rs/exec-server/src/client/reqwest_http_client.rs index 7ead01c9ba..d0cffa6a32 100644 --- a/codex-rs/exec-server/src/client/reqwest_http_client.rs +++ b/codex-rs/exec-server/src/client/reqwest_http_client.rs @@ -8,9 +8,9 @@ use std::error::Error as StdError; use std::time::Duration; -use codex_client::build_reqwest_client_with_custom_ca; -use codex_client::with_chatgpt_cloudflare_cookie_store; use codex_exec_server_protocol::JSONRPCErrorError; +use codex_http_client::build_reqwest_client_with_custom_ca; +use codex_http_client::with_chatgpt_cloudflare_cookie_store; use futures::FutureExt; use futures::StreamExt; use futures::future::BoxFuture; diff --git a/codex-rs/exec-server/src/noise_relay/harness_tests.rs b/codex-rs/exec-server/src/noise_relay/harness_tests.rs index ce26a8ca8c..348351e61c 100644 --- a/codex-rs/exec-server/src/noise_relay/harness_tests.rs +++ b/codex-rs/exec-server/src/noise_relay/harness_tests.rs @@ -31,7 +31,7 @@ use crate::noise_channel::PendingResponderHandshake; const ENVIRONMENT_ID: &str = "environment-1"; const EXECUTOR_REGISTRATION_ID: &str = "registration-1"; -#[tokio::test] +#[tokio::test(start_paused = true)] async fn fragmented_writes_yield_to_keepalive_and_queued_pong() -> Result<()> { let (connection, mut control, mut outbound_rx) = connected_controlled_harness().await?; @@ -48,7 +48,7 @@ async fn fragmented_writes_yield_to_keepalive_and_queued_pong() -> Result<()> { .await?; control.wait_for_blocked_write(/*expected*/ 1).await?; - tokio::time::sleep(WEBSOCKET_KEEPALIVE_INTERVAL + Duration::from_millis(10)).await; + tokio::time::advance(WEBSOCKET_KEEPALIVE_INTERVAL + Duration::from_millis(10)).await; control.grant_writes(/*count*/ 1); let first_data = read_outbound_data(&mut outbound_rx).await?; assert_eq!(first_data.seq, 0); @@ -64,7 +64,7 @@ async fn fragmented_writes_yield_to_keepalive_and_queued_pong() -> Result<()> { control.wait_for_blocked_write(/*expected*/ 3).await?; control.send_inbound(Message::Pong(ping_payload))?; - tokio::time::sleep(WEBSOCKET_KEEPALIVE_INTERVAL + Duration::from_millis(10)).await; + tokio::time::advance(WEBSOCKET_KEEPALIVE_INTERVAL + Duration::from_millis(10)).await; control.grant_writes(/*count*/ 1); let second_data = read_outbound_data(&mut outbound_rx).await?; assert_eq!(second_data.seq, 1); diff --git a/codex-rs/exec-server/src/rpc.rs b/codex-rs/exec-server/src/rpc.rs index 46a0bd14e8..e762fe8b66 100644 --- a/codex-rs/exec-server/src/rpc.rs +++ b/codex-rs/exec-server/src/rpc.rs @@ -342,6 +342,16 @@ impl RpcClient { drain_pending(&self.pending).await; } + #[tracing::instrument( + name = "codex.exec_server.request", + level = "info", + skip_all, + fields( + otel.kind = "client", + otel.name = method, + method, + ) + )] pub(crate) async fn call(&self, method: &str, params: &P) -> Result where P: Serialize, @@ -803,6 +813,15 @@ mod tests { .expect("RPC response"); assert_eq!(response, serde_json::json!({})); let trace = server.await.expect("server task").expect("trace context"); - assert_eq!(trace, expected_trace); + let expected_traceparent = expected_trace + .traceparent + .as_deref() + .expect("parent traceparent"); + let traceparent = trace.traceparent.as_deref().expect("request traceparent"); + let expected_parts = expected_traceparent.split('-').collect::>(); + let parts = traceparent.split('-').collect::>(); + assert_eq!(parts[1], expected_parts[1]); + assert_ne!(parts[2], expected_parts[2]); + assert_eq!(trace.tracestate, expected_trace.tracestate); } } diff --git a/codex-rs/exec-server/testing/BUILD.bazel b/codex-rs/exec-server/testing/BUILD.bazel index ac4818f55e..2e2cc220d4 100644 --- a/codex-rs/exec-server/testing/BUILD.bazel +++ b/codex-rs/exec-server/testing/BUILD.bazel @@ -32,13 +32,11 @@ rust_binary( ) rust_binary( - name = "windows-exec-server", + name = "exec-server", testonly = True, - srcs = ["windows_exec_server.rs"], - crate_name = "windows_exec_server", - crate_root = "windows_exec_server.rs", - tags = ["manual"], - target_compatible_with = ["@platforms//os:windows"], + srcs = ["exec_server.rs"], + crate_name = "exec_server", + crate_root = "exec_server.rs", visibility = ["//visibility:public"], deps = [ "//codex-rs/exec-server", diff --git a/codex-rs/exec-server/testing/exec_server.rs b/codex-rs/exec-server/testing/exec_server.rs new file mode 100644 index 0000000000..dbef4a4899 --- /dev/null +++ b/codex-rs/exec-server/testing/exec_server.rs @@ -0,0 +1,14 @@ +//! Minimal exec-server fixture for Bazel-only integration tests. +//! +//! Linking only exec-server avoids depending on the full Codex CLI binary +//! when a test only needs a WebSocket executor endpoint. + +use codex_exec_server::ExecServerRuntimePaths; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let current_exe = std::env::current_exe()?; + let runtime_paths = + ExecServerRuntimePaths::new(current_exe, /*codex_linux_sandbox_exe*/ None)?; + codex_exec_server::run_main("ws://127.0.0.1:0", runtime_paths).await +} diff --git a/codex-rs/exec-server/testing/windows_exec_server.rs b/codex-rs/exec-server/testing/windows_exec_server.rs deleted file mode 100644 index 9efc5286fc..0000000000 --- a/codex-rs/exec-server/testing/windows_exec_server.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Minimal Windows exec-server fixture for cross-platform tests. -//! -//! Keeping this wrapper separate avoids depending on the full Codex binary's -//! Windows cross-build, which is not yet supported by the Bazel graph. Linking -//! only the exec-server also makes the Wine test substantially faster to -//! iterate on. - -use codex_exec_server::ExecServerRuntimePaths; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let current_exe = std::env::current_exe()?; - // This fixture is always a Windows executable, so it neither invokes nor - // needs the separate Linux sandbox binary. - let runtime_paths = - ExecServerRuntimePaths::new(current_exe, /*codex_linux_sandbox_exe*/ None)?; - codex_exec_server::run_main("ws://127.0.0.1:0", runtime_paths).await -} diff --git a/codex-rs/exec-server/tests/file_system/shared.rs b/codex-rs/exec-server/tests/file_system/shared.rs index 261c6b07d0..236a9623ee 100644 --- a/codex-rs/exec-server/tests/file_system/shared.rs +++ b/codex-rs/exec-server/tests/file_system/shared.rs @@ -402,6 +402,7 @@ async fn file_system_walk_returns_a_bounded_tree( max_directories: 10, max_entries: 10, follow_directory_symlinks: false, + prune_hidden_directories: false, }, /*sandbox*/ None, ) @@ -447,6 +448,7 @@ async fn file_system_walk_returns_a_bounded_tree( max_directories: 10, max_entries: 10, follow_directory_symlinks: false, + prune_hidden_directories: false, }, /*sandbox*/ None, ) @@ -469,6 +471,7 @@ async fn file_system_walk_returns_a_bounded_tree( max_directories: 1, max_entries: 10, follow_directory_symlinks: false, + prune_hidden_directories: false, }, /*sandbox*/ None, ) @@ -491,6 +494,7 @@ async fn file_system_walk_returns_a_bounded_tree( max_directories: 10, max_entries: 1, follow_directory_symlinks: false, + prune_hidden_directories: false, }, /*sandbox*/ None, ) @@ -535,6 +539,7 @@ async fn file_system_walk_honors_read_sandbox( max_directories: 2, max_entries: 2, follow_directory_symlinks: false, + prune_hidden_directories: false, }, Some(&sandbox), ) diff --git a/codex-rs/exec-server/tests/file_system_unix.rs b/codex-rs/exec-server/tests/file_system_unix.rs index f658045441..c3fc680787 100644 --- a/codex-rs/exec-server/tests/file_system_unix.rs +++ b/codex-rs/exec-server/tests/file_system_unix.rs @@ -299,6 +299,7 @@ async fn file_system_walk_handles_directory_symlinks( max_directories: 4, max_entries: 8, follow_directory_symlinks: false, + prune_hidden_directories: false, }, /*sandbox*/ None, ) @@ -321,6 +322,7 @@ async fn file_system_walk_handles_directory_symlinks( max_directories: 4, max_entries: 8, follow_directory_symlinks: true, + prune_hidden_directories: false, }, /*sandbox*/ None, ) @@ -351,6 +353,68 @@ async fn file_system_walk_handles_directory_symlinks( Ok(()) } +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_walk_prunes_hidden_directories_without_claiming_visible_aliases( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let root = tmp.path().join("root"); + let hidden = root.join(".hidden"); + let hidden_nested = hidden.join("nested"); + let visible = root.join("visible"); + std::fs::create_dir_all(&hidden_nested)?; + std::fs::write(hidden_nested.join("note.txt"), "visible through alias")?; + symlink(&hidden, &visible)?; + + let outcome = file_system + .walk( + &PathUri::from_host_native_path(&root)?, + WalkOptions { + max_depth: 3, + max_directories: 3, + max_entries: 6, + follow_directory_symlinks: true, + prune_hidden_directories: true, + }, + /*sandbox*/ None, + ) + .await + .with_context(|| format!("mode={implementation}"))?; + + assert_eq!( + outcome, + WalkOutcome { + entries: vec![ + WalkEntry { + path: PathUri::from_host_native_path(hidden)?, + kind: WalkEntryKind::Directory, + }, + WalkEntry { + path: PathUri::from_host_native_path(&visible)?, + kind: WalkEntryKind::Directory, + }, + WalkEntry { + path: PathUri::from_host_native_path(visible.join("nested"))?, + kind: WalkEntryKind::Directory, + }, + WalkEntry { + path: PathUri::from_host_native_path(visible.join("nested/note.txt"))?, + kind: WalkEntryKind::File, + }, + ], + errors: Vec::new(), + truncated: false, + } + ); + + Ok(()) +} + #[test_case(FileSystemImplementation::Local ; "local")] #[test_case(FileSystemImplementation::Remote ; "remote")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/ext/extension-api/src/contributors/mcp.rs b/codex-rs/ext/extension-api/src/contributors/mcp.rs index 499657f556..3fe648a1e9 100644 --- a/codex-rs/ext/extension-api/src/contributors/mcp.rs +++ b/codex-rs/ext/extension-api/src/contributors/mcp.rs @@ -93,8 +93,8 @@ pub enum McpServerContribution { selection_order: usize, config: Box, }, - /// Adds connector IDs declared by a plugin selected for this thread. - SelectedPluginConnectors { + /// Records a plugin selected for this thread and any connector IDs it declares. + SelectedPluginPackage { plugin_id: String, plugin_display_name: String, connector_ids: Vec, diff --git a/codex-rs/ext/image-generation/Cargo.toml b/codex-rs/ext/image-generation/Cargo.toml index c5139ac12b..ad10b2b0a6 100644 --- a/codex-rs/ext/image-generation/Cargo.toml +++ b/codex-rs/ext/image-generation/Cargo.toml @@ -18,6 +18,7 @@ codex-api = { workspace = true } codex-core = { workspace = true } codex-exec-server = { workspace = true } codex-extension-api = { workspace = true } +codex-extension-items = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 3bd4dc5348..06a4351a42 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -23,7 +23,8 @@ use codex_extension_api::ToolOutput; use codex_extension_api::ToolPayload; use codex_extension_api::ToolSpec; use codex_extension_api::parse_tool_input_schema; -use codex_protocol::items::ImageGenerationItem; +use codex_extension_items::ExtensionItem; +use codex_extension_items::image_generation::ImageGenerationItem; use codex_protocol::models::ContentItem; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputBody; @@ -31,6 +32,9 @@ use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ImageGenerationBeginEvent; +use codex_protocol::protocol::ImageGenerationEndEvent; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ResponsesApiTool; @@ -86,6 +90,23 @@ struct ImagegenArgs { num_last_images_to_include: Option, } +fn legacy_end_event(item: &ImageGenerationItem) -> EventMsg { + EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { + call_id: item.id.clone(), + status: item.status.clone(), + revised_prompt: item.revised_prompt.clone(), + result: item.result.clone(), + saved_path: item.saved_path.clone(), + }) +} + +fn extension_turn_item(item: ImageGenerationItem, legacy_event: EventMsg) -> ExtensionTurnItem { + ExtensionTurnItem::Extension { + item: ExtensionItem::ImageGeneration(item), + legacy_events: vec![legacy_event], + } +} + impl ToolExecutor for ImageGenerationTool { /// Keeps the tool in the existing image-generation Responses namespace. fn tool_name(&self) -> ToolName { @@ -115,13 +136,18 @@ impl ImageGenerationTool { request_for_call_args(&args, call.conversation_history.items(), &call.environments) .await?; call.turn_item_emitter - .emit_started(ExtensionTurnItem::ImageGeneration(ImageGenerationItem { - id: call.call_id.clone(), - status: "in_progress".to_string(), - revised_prompt: None, - result: String::new(), - saved_path: None, - })) + .emit_started(extension_turn_item( + ImageGenerationItem { + id: call.call_id.clone(), + status: "in_progress".to_string(), + revised_prompt: None, + result: String::new(), + saved_path: None, + }, + EventMsg::ImageGenerationBegin(ImageGenerationBeginEvent { + call_id: call.call_id.clone(), + }), + )) .await; let result = match request { ImageRequest::Generate(request) => self.backend.generate(request).await, @@ -139,14 +165,16 @@ impl ImageGenerationTool { let result = match result { Ok(result) => result, Err(message) => { + let item = ImageGenerationItem { + id: call.call_id.clone(), + status: "failed".to_string(), + revised_prompt: Some(args.prompt), + result: String::new(), + saved_path: None, + }; + let legacy_event = legacy_end_event(&item); call.turn_item_emitter - .emit_completed(ExtensionTurnItem::ImageGeneration(ImageGenerationItem { - id: call.call_id.clone(), - status: "failed".to_string(), - revised_prompt: Some(args.prompt.clone()), - result: String::new(), - saved_path: None, - })) + .emit_completed(extension_turn_item(item, legacy_event)) .await; return Err(FunctionCallError::RespondToModel(message)); } @@ -176,14 +204,16 @@ impl ImageGenerationTool { }, None => None, }; + let item = ImageGenerationItem { + id: call.call_id.clone(), + status: "completed".to_string(), + revised_prompt: Some(args.prompt), + result: result.clone(), + saved_path: saved_path.clone(), + }; + let legacy_event = legacy_end_event(&item); call.turn_item_emitter - .emit_completed(ExtensionTurnItem::ImageGeneration(ImageGenerationItem { - id: call.call_id.clone(), - status: "completed".to_string(), - revised_prompt: Some(args.prompt), - result: result.clone(), - saved_path: saved_path.clone(), - })) + .emit_completed(extension_turn_item(item, legacy_event)) .await; let output_hint = saved_path.as_ref().and_then(|output_path| { let output_dir = output_path.parent()?; diff --git a/codex-rs/ext/items/BUILD.bazel b/codex-rs/ext/items/BUILD.bazel new file mode 100644 index 0000000000..9bc0c2b673 --- /dev/null +++ b/codex-rs/ext/items/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "items", + crate_name = "codex_extension_items", +) diff --git a/codex-rs/ext/items/Cargo.toml b/codex-rs/ext/items/Cargo.toml new file mode 100644 index 0000000000..fdf848eddf --- /dev/null +++ b/codex-rs/ext/items/Cargo.toml @@ -0,0 +1,23 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-extension-items" +version.workspace = true + +[lib] +name = "codex_extension_items" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +codex-utils-absolute-path = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +ts-rs = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +serde_json = { workspace = true } diff --git a/codex-rs/ext/items/src/image_generation.rs b/codex-rs/ext/items/src/image_generation.rs new file mode 100644 index 0000000000..7b740421f9 --- /dev/null +++ b/codex-rs/ext/items/src/image_generation.rs @@ -0,0 +1,21 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +// Standalone image-generation item owned by the image extension. This is also +// the field-level representation exposed by app-server; core and rollout +// persistence only carry it inside an ExtensionItem envelope. +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub struct ImageGenerationItem { + pub id: String, + pub status: String, + pub revised_prompt: Option, + pub result: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub saved_path: Option, +} diff --git a/codex-rs/ext/items/src/lib.rs b/codex-rs/ext/items/src/lib.rs new file mode 100644 index 0000000000..27eeb9abb0 --- /dev/null +++ b/codex-rs/ext/items/src/lib.rs @@ -0,0 +1,51 @@ +//! Typed display items owned by Codex extensions. +//! +//! This crate intentionally sits below `codex-protocol` so core can carry +//! extension items without owning each extension's display schema. + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +pub mod image_generation; + +/// Canonical extension-owned turn item carried through core lifecycle events. +/// +/// The item is serialized as a flattened, namespaced envelope: +/// +/// ```json +/// { +/// "kind": "image_gen.generation", +/// "id": "call-id", +/// "status": "completed", +/// "revisedPrompt": "A blue square", +/// "result": "cG5n", +/// "savedPath": "/tmp/image.png" +/// } +/// ``` +/// +/// `kind` values follow `.`. Adding a variant +/// also requires app-server to add its typed public wrapper. +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +#[serde(tag = "kind")] +#[ts(tag = "kind")] +pub enum ExtensionItem { + #[serde(rename = "image_gen.generation")] + #[ts(rename = "image_gen.generation")] + ImageGeneration(image_generation::ImageGenerationItem), +} + +impl ExtensionItem { + /// Returns the stable item identifier without exposing variant fields to + /// core or rollout persistence. + pub fn id(&self) -> &str { + match self { + Self::ImageGeneration(item) => &item.id, + } + } +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/codex-rs/ext/items/src/tests.rs b/codex-rs/ext/items/src/tests.rs new file mode 100644 index 0000000000..88575b4bfe --- /dev/null +++ b/codex-rs/ext/items/src/tests.rs @@ -0,0 +1,57 @@ +use pretty_assertions::assert_eq; +use serde_json::json; + +use super::ExtensionItem; +use super::image_generation::ImageGenerationItem; + +fn completed_image_generation_item() -> ExtensionItem { + ExtensionItem::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: None, + }) +} + +#[test] +fn image_generation_item_preserves_stable_wire_shape() { + let item = completed_image_generation_item(); + let value = serde_json::to_value(&item).expect("serialize extension item"); + + assert_eq!( + value, + json!({ + "kind": "image_gen.generation", + "id": "image-1", + "status": "completed", + "revisedPrompt": "A blue square", + "result": "cG5n", + }) + ); + assert_eq!( + serde_json::from_value::(value).expect("deserialize extension item"), + item + ); +} + +#[test] +fn unknown_extension_kind_is_rejected() { + let value = json!({ + "kind": "image_gen.unknown", + "id": "image-1", + }); + + assert!(serde_json::from_value::(value).is_err()); +} + +#[test] +fn malformed_known_extension_payload_is_rejected() { + let value = json!({ + "kind": "image_gen.generation", + "id": "image-1", + "status": "completed", + }); + + assert!(serde_json::from_value::(value).is_err()); +} diff --git a/codex-rs/ext/mcp/src/executor_plugin.rs b/codex-rs/ext/mcp/src/executor_plugin.rs index 32637b4f47..98cfcd3615 100644 --- a/codex-rs/ext/mcp/src/executor_plugin.rs +++ b/codex-rs/ext/mcp/src/executor_plugin.rs @@ -186,13 +186,12 @@ impl McpServerContributor for SelectedExecutorPluginMcpContributor { config: Box::new(config), } })); - if !plugin.connector_ids.is_empty() { - contributions.push(McpServerContribution::SelectedPluginConnectors { - plugin_id: plugin.plugin_id, - plugin_display_name: plugin.plugin_display_name, - connector_ids: plugin.connector_ids, - }); - } + // Keep the package visible even when it contributes only skills. + contributions.push(McpServerContribution::SelectedPluginPackage { + plugin_id: plugin.plugin_id, + plugin_display_name: plugin.plugin_display_name, + connector_ids: plugin.connector_ids, + }); } contributions diff --git a/codex-rs/ext/mcp/tests/executor_plugin_mcp.rs b/codex-rs/ext/mcp/tests/executor_plugin_mcp.rs index 65a4248a32..da5c1fb6ad 100644 --- a/codex-rs/ext/mcp/tests/executor_plugin_mcp.rs +++ b/codex-rs/ext/mcp/tests/executor_plugin_mcp.rs @@ -25,6 +25,13 @@ struct ContributionSummary { enabled: bool, } +#[derive(Debug, PartialEq, Eq)] +struct PackageSummary { + plugin_id: String, + plugin_display_name: String, + connector_ids: Vec, +} + #[tokio::test] async fn selected_plugin_servers_use_managed_requirements_for_the_selected_root_id() -> TestResult { let codex_home = tempfile::tempdir()?; @@ -92,10 +99,87 @@ command = "expected-command" Ok(()) } +#[tokio::test] +async fn selected_plugin_package_is_contributed_without_servers_or_connectors() -> TestResult { + let codex_home = tempfile::tempdir()?; + let plugin_root = tempfile::tempdir()?; + std::fs::create_dir_all(plugin_root.path().join(".codex-plugin"))?; + std::fs::create_dir_all(plugin_root.path().join("skills/deploy"))?; + std::fs::write( + plugin_root.path().join(".codex-plugin/plugin.json"), + r#"{"name":"skill-only","interface":{"displayName":"Skill Only"}}"#, + )?; + std::fs::write( + plugin_root.path().join("skills/deploy/SKILL.md"), + "---\nname: deploy\ndescription: Deploy the project.\n---\n", + )?; + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await?; + + let contributions = raw_selected_plugin_contributions(&config, plugin_root.path()).await?; + let package = contributions.into_iter().find_map(|contribution| { + let McpServerContribution::SelectedPluginPackage { + plugin_id, + plugin_display_name, + connector_ids, + } = contribution + else { + return None; + }; + Some(PackageSummary { + plugin_id, + plugin_display_name, + connector_ids, + }) + }); + + assert_eq!( + package, + Some(PackageSummary { + plugin_id: "selected-root".to_string(), + plugin_display_name: "Skill Only".to_string(), + connector_ids: Vec::new(), + }) + ); + Ok(()) +} + async fn selected_plugin_contributions( config: &Config, plugin_root: &std::path::Path, ) -> Result, Box> { + Ok(raw_selected_plugin_contributions(config, plugin_root) + .await? + .into_iter() + .filter_map(|contribution| match contribution { + McpServerContribution::SelectedPlugin { + name, + plugin_id, + plugin_display_name, + selection_order, + config, + } => Some(ContributionSummary { + name, + plugin_id, + plugin_display_name, + selection_order, + enabled: config.enabled, + }), + McpServerContribution::SelectedPluginPackage { .. } => None, + McpServerContribution::Set { .. } | McpServerContribution::Remove { .. } => { + panic!("expected selected plugin contribution") + } + }) + .collect()) +} + +async fn raw_selected_plugin_contributions( + config: &Config, + plugin_root: &std::path::Path, +) -> Result, Box> { let mut builder = ExtensionRegistryBuilder::new(); codex_mcp_extension::install_executor_plugins( &mut builder, @@ -120,27 +204,5 @@ async fn selected_plugin_contributions( &thread_store, &available_environment_ids, )) - .await - .into_iter() - .map(|contribution| match contribution { - McpServerContribution::SelectedPlugin { - name, - plugin_id, - plugin_display_name, - selection_order, - config, - } => ContributionSummary { - name, - plugin_id, - plugin_display_name, - selection_order, - enabled: config.enabled, - }, - McpServerContribution::Set { .. } - | McpServerContribution::SelectedPluginConnectors { .. } - | McpServerContribution::Remove { .. } => { - panic!("expected selected plugin contribution") - } - }) - .collect()) + .await) } diff --git a/codex-rs/ext/skills/src/state.rs b/codex-rs/ext/skills/src/state.rs index 2f1a84c46b..2094f34496 100644 --- a/codex-rs/ext/skills/src/state.rs +++ b/codex-rs/ext/skills/src/state.rs @@ -66,6 +66,12 @@ impl SkillsThreadState { /// Environment availability only controls whether the root is projected into the current /// step; it never invalidates the cache. There is intentionally no filesystem watcher or /// content-based invalidation because selected environment roots are treated as stable. + #[tracing::instrument( + name = "skills.executor.catalog_snapshot", + level = "info", + skip_all, + fields(root_count = query.executor_roots.len()) + )] pub(crate) async fn executor_catalog_snapshot( &self, providers: &SkillProviders, @@ -157,6 +163,7 @@ impl SkillsThreadState { next_cache } + #[tracing::instrument(name = "skills.executor.catalog_root", level = "info", skip_all)] async fn executor_root_catalog( &self, providers: &SkillProviders, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 7ab7df18d1..166dc912fd 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -204,6 +204,8 @@ pub enum Feature { ResizeAllImages, /// Generate Responses API item IDs for client-created history items. ItemIds, + /// Request sequential cutoff reasoning summary delivery. + ConcurrentReasoningSummaries, /// Allow prompting and installing missing MCP dependencies. SkillMcpDependencyInstall, /// Removed compatibility flag for deleted skill env var dependency prompting. @@ -1184,6 +1186,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::ConcurrentReasoningSummaries, + key: "concurrent_reasoning_summaries", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::SkillMcpDependencyInstall, key: "skill_mcp_dependency_install", @@ -1265,8 +1273,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::AuthElicitation, key: "auth_elicitation", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::Personality, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 13abd9ecd2..7291c4e1b1 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -316,9 +316,9 @@ fn tool_call_mcp_elicitation_is_stable_and_enabled_by_default() { } #[test] -fn auth_elicitation_is_under_development() { - assert_eq!(Feature::AuthElicitation.stage(), Stage::UnderDevelopment); - assert_eq!(Feature::AuthElicitation.default_enabled(), false); +fn auth_elicitation_is_stable_and_enabled_by_default() { + assert_eq!(Feature::AuthElicitation.stage(), Stage::Stable); + assert_eq!(Feature::AuthElicitation.default_enabled(), true); assert_eq!( feature_for_key("auth_elicitation"), Some(Feature::AuthElicitation) diff --git a/codex-rs/file-system/src/lib.rs b/codex-rs/file-system/src/lib.rs index 737412f463..2ef3bcf710 100644 --- a/codex-rs/file-system/src/lib.rs +++ b/codex-rs/file-system/src/lib.rs @@ -81,6 +81,9 @@ pub struct WalkOptions { pub max_entries: usize, /// Whether directory symlinks should be followed. pub follow_directory_symlinks: bool, + /// Whether directories whose names start with `.` should be returned but not traversed. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub prune_hidden_directories: bool, } /// Type of a filesystem entry returned by a walk. @@ -463,6 +466,9 @@ async fn walk_via_directory_reads( }); if kind == WalkEntryKind::Directory && depth < options.max_depth { + if options.prune_hidden_directories && entry.file_name.starts_with('.') { + continue; + } let directory_identity = if options.follow_directory_symlinks { match file_system.canonicalize(&path, sandbox).await { Ok(path) => path, diff --git a/codex-rs/git-utils/src/info.rs b/codex-rs/git-utils/src/info.rs index d14cd1e0b1..115e26110d 100644 --- a/codex-rs/git-utils/src/info.rs +++ b/codex-rs/git-utils/src/info.rs @@ -864,8 +864,11 @@ fn find_ancestor_git_entry(base_dir: &Path) -> Option<(PathBuf, PathBuf)> { /// Returns a list of local git branches. /// Includes the default branch at the beginning of the list, if it exists. pub async fn local_git_branches(cwd: &Path) -> Vec { - let mut branches: Vec = if let Some(out) = - run_git_command_with_timeout(&["branch", "--format=%(refname:short)"], cwd).await + let mut branches: Vec = if let Some(out) = run_git_command_with_timeout( + &["for-each-ref", "--format=%(refname:short)", "refs/heads"], + cwd, + ) + .await && out.status.success() { String::from_utf8_lossy(&out.stdout) @@ -945,6 +948,45 @@ mod tests { } } + #[tokio::test] + async fn local_git_branches_excludes_detached_head_entry() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let repo = temp_dir.path(); + let envs = vec![ + ("GIT_CONFIG_GLOBAL", "/dev/null"), + ("GIT_CONFIG_NOSYSTEM", "1"), + ]; + let run_git = |args: &[&str]| { + let status = std::process::Command::new("git") + .envs(envs.clone()) + .args(args) + .current_dir(repo) + .status() + .expect("run Git command"); + assert_eq!(status.code(), Some(0), "Git command failed: {args:?}"); + }; + + run_git(&["init", "-q", "--initial-branch=main"]); + run_git(&[ + "-c", + "user.name=Codex Tests", + "-c", + "user.email=codex-tests@example.com", + "commit", + "--allow-empty", + "-q", + "-m", + "initial", + ]); + run_git(&["branch", "feature/local"]); + run_git(&["checkout", "--detach", "-q"]); + + assert_eq!( + local_git_branches(repo).await, + vec!["main".to_string(), "feature/local".to_string()] + ); + } + #[cfg(unix)] #[tokio::test] async fn fsmonitor_override_rejects_configured_helper() { diff --git a/codex-rs/hooks/src/engine/command_runner.rs b/codex-rs/hooks/src/engine/command_runner.rs index 7366d4ec51..9f4c4e6059 100644 --- a/codex-rs/hooks/src/engine/command_runner.rs +++ b/codex-rs/hooks/src/engine/command_runner.rs @@ -6,9 +6,18 @@ use std::time::Instant; use tokio::io::AsyncWriteExt; use tokio::process::Command; use tokio::time::timeout; +use tracing::Span; use super::CommandShell; use super::ConfiguredHandler; +use super::dispatcher::hook_event_name_label; +use super::dispatcher::hook_execution_mode_label; +use super::dispatcher::hook_handler_type_label; +use super::dispatcher::hook_scope_label; +use super::dispatcher::hook_source_label; +use super::dispatcher::scope_for_event; +use codex_protocol::protocol::HookExecutionMode; +use codex_protocol::protocol::HookHandlerType; #[derive(Debug)] pub(crate) struct CommandRunResult { @@ -21,9 +30,26 @@ pub(crate) struct CommandRunResult { pub error: Option, } +#[tracing::instrument( + name = "codex.hooks.command", + level = "trace", + skip_all, + fields( + hook.event_name = hook_event_name_label(handler.event_name), + hook.handler_type = hook_handler_type_label(HookHandlerType::Command), + hook.execution_mode = hook_execution_mode_label(HookExecutionMode::Sync), + hook.scope = hook_scope_label(scope_for_event(handler.event_name)), + hook.source = hook_source_label(handler.source), + hook.display_order = handler.display_order, + hook.configured_order = configured_order, + hook.timeout_sec = handler.timeout_sec, + hook.command_outcome = tracing::field::Empty, + ) +)] pub(crate) async fn run_command( shell: &CommandShell, handler: &ConfiguredHandler, + configured_order: usize, input_json: &str, cwd: &Path, ) -> CommandRunResult { @@ -41,15 +67,17 @@ pub(crate) async fn run_command( let mut child = match command.spawn() { Ok(child) => child, Err(err) => { - return CommandRunResult { + return finish_command_run( started_at, - completed_at: chrono::Utc::now().timestamp(), - duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX), - exit_code: None, - stdout: String::new(), - stderr: String::new(), - error: Some(err.to_string()), - }; + started, + CommandRunCompletion { + exit_code: None, + stdout: String::new(), + stderr: String::new(), + error: Some(err.to_string()), + outcome: "spawn_error", + }, + ); } }; @@ -57,46 +85,79 @@ pub(crate) async fn run_command( && let Err(err) = stdin.write_all(input_json.as_bytes()).await { let _ = child.kill().await; - return CommandRunResult { + return finish_command_run( started_at, - completed_at: chrono::Utc::now().timestamp(), - duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX), - exit_code: None, - stdout: String::new(), - stderr: String::new(), - error: Some(format!("failed to write hook stdin: {err}")), - }; + started, + CommandRunCompletion { + exit_code: None, + stdout: String::new(), + stderr: String::new(), + error: Some(format!("failed to write hook stdin: {err}")), + outcome: "stdin_error", + }, + ); } let timeout_duration = Duration::from_secs(handler.timeout_sec); match timeout(timeout_duration, child.wait_with_output()).await { - Ok(Ok(output)) => CommandRunResult { + Ok(Ok(output)) => finish_command_run( started_at, - completed_at: chrono::Utc::now().timestamp(), - duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX), - exit_code: output.status.code(), - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - error: None, - }, - Ok(Err(err)) => CommandRunResult { + started, + CommandRunCompletion { + exit_code: output.status.code(), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + error: None, + outcome: "completed", + }, + ), + Ok(Err(err)) => finish_command_run( started_at, - completed_at: chrono::Utc::now().timestamp(), - duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX), - exit_code: None, - stdout: String::new(), - stderr: String::new(), - error: Some(err.to_string()), - }, - Err(_) => CommandRunResult { + started, + CommandRunCompletion { + exit_code: None, + stdout: String::new(), + stderr: String::new(), + error: Some(err.to_string()), + outcome: "wait_error", + }, + ), + Err(_) => finish_command_run( started_at, - completed_at: chrono::Utc::now().timestamp(), - duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX), - exit_code: None, - stdout: String::new(), - stderr: String::new(), - error: Some(format!("hook timed out after {}s", handler.timeout_sec)), - }, + started, + CommandRunCompletion { + exit_code: None, + stdout: String::new(), + stderr: String::new(), + error: Some(format!("hook timed out after {}s", handler.timeout_sec)), + outcome: "timeout", + }, + ), + } +} + +struct CommandRunCompletion { + exit_code: Option, + stdout: String, + stderr: String, + error: Option, + outcome: &'static str, +} + +fn finish_command_run( + started_at: i64, + started: Instant, + completion: CommandRunCompletion, +) -> CommandRunResult { + Span::current().record("hook.command_outcome", completion.outcome); + CommandRunResult { + started_at, + completed_at: chrono::Utc::now().timestamp(), + duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX), + exit_code: completion.exit_code, + stdout: completion.stdout, + stderr: completion.stderr, + error: completion.error, } } diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index 50822bfc96..ccf5c2c8e6 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -99,7 +99,7 @@ pub(crate) async fn execute_handlers( let input_json = input_json.clone(); let turn_id = turn_id.clone(); pending.push(async move { - let result = run_command(shell, &handler, &input_json, cwd).await; + let result = run_command(shell, &handler, configured_order, &input_json, cwd).await; (configured_order, parse(&handler, result, turn_id)) }); } @@ -139,7 +139,7 @@ pub(crate) fn completed_summary( } } -fn scope_for_event(event_name: HookEventName) -> HookScope { +pub(crate) fn scope_for_event(event_name: HookEventName) -> HookScope { match event_name { HookEventName::SessionStart | HookEventName::SubagentStart => HookScope::Thread, HookEventName::PreToolUse @@ -153,12 +153,68 @@ fn scope_for_event(event_name: HookEventName) -> HookScope { } } +pub(crate) fn hook_event_name_label(event_name: HookEventName) -> &'static str { + match event_name { + HookEventName::PreToolUse => "PreToolUse", + HookEventName::PermissionRequest => "PermissionRequest", + HookEventName::PostToolUse => "PostToolUse", + HookEventName::PreCompact => "PreCompact", + HookEventName::PostCompact => "PostCompact", + HookEventName::SessionStart => "SessionStart", + HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::SubagentStart => "SubagentStart", + HookEventName::SubagentStop => "SubagentStop", + HookEventName::Stop => "Stop", + } +} + +pub(crate) fn hook_execution_mode_label(mode: HookExecutionMode) -> &'static str { + match mode { + HookExecutionMode::Sync => "sync", + HookExecutionMode::Async => "async", + } +} + +pub(crate) fn hook_handler_type_label(handler_type: HookHandlerType) -> &'static str { + match handler_type { + HookHandlerType::Command => "command", + HookHandlerType::Prompt => "prompt", + HookHandlerType::Agent => "agent", + } +} + +pub(crate) fn hook_scope_label(scope: HookScope) -> &'static str { + match scope { + HookScope::Thread => "thread", + HookScope::Turn => "turn", + } +} + +pub(crate) fn hook_source_label(source: codex_protocol::protocol::HookSource) -> &'static str { + match source { + codex_protocol::protocol::HookSource::System => "system", + codex_protocol::protocol::HookSource::User => "user", + codex_protocol::protocol::HookSource::Project => "project", + codex_protocol::protocol::HookSource::Mdm => "mdm", + codex_protocol::protocol::HookSource::SessionFlags => "session_flags", + codex_protocol::protocol::HookSource::Plugin => "plugin", + codex_protocol::protocol::HookSource::CloudRequirements => "cloud_requirements", + codex_protocol::protocol::HookSource::CloudManagedConfig => "cloud_managed_config", + codex_protocol::protocol::HookSource::LegacyManagedConfigFile => { + "legacy_managed_config_file" + } + codex_protocol::protocol::HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm", + codex_protocol::protocol::HookSource::Unknown => "unknown", + } +} + #[cfg(test)] mod tests { use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookSource; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; + use pretty_assertions::assert_eq; use super::ConfiguredHandler; use super::select_handlers; diff --git a/codex-rs/http-client/BUILD.bazel b/codex-rs/http-client/BUILD.bazel new file mode 100644 index 0000000000..6092c05bf2 --- /dev/null +++ b/codex-rs/http-client/BUILD.bazel @@ -0,0 +1,7 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "http-client", + compile_data = glob(["tests/fixtures/**"]), + crate_name = "codex_http_client", +) diff --git a/codex-rs/http-client/Cargo.toml b/codex-rs/http-client/Cargo.toml new file mode 100644 index 0000000000..46afa51ba5 --- /dev/null +++ b/codex-rs/http-client/Cargo.toml @@ -0,0 +1,49 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-http-client" +version.workspace = true + +[dependencies] +bytes = { workspace = true } +codex-utils-rustls-provider = { workspace = true } +futures = { workspace = true } +http = { workspace = true } +opentelemetry = { workspace = true } +reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots", "stream"] } +rustls = { workspace = true } +rustls-native-certs = { workspace = true } +rustls-pki-types = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "time", "sync"] } +tracing = { workspace = true } +tracing-opentelemetry = { workspace = true } +zstd = { workspace = true } + +[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] +sha2 = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +system-configuration = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.52", features = [ + "Win32_Foundation", + "Win32_Networking_WinHttp", +] } + +[lints] +workspace = true + +[dev-dependencies] +codex-utils-cargo-bin = { workspace = true } +opentelemetry_sdk = { workspace = true } +pretty_assertions = { workspace = true } +rcgen = { workspace = true } +tempfile = { workspace = true } +tracing-subscriber = { workspace = true } + +[lib] +doctest = false diff --git a/codex-rs/http-client/README.md b/codex-rs/http-client/README.md new file mode 100644 index 0000000000..89e35ac59d --- /dev/null +++ b/codex-rs/http-client/README.md @@ -0,0 +1,9 @@ +# codex-http-client + +Low-level HTTP transport shared by Codex crates. + +- Defines the request, response, streaming, and transport types used for outbound HTTP calls. +- Owns the `reqwest` implementation, custom CA handling, and ChatGPT Cloudflare cookie policy. +- Resolves system, PAC/WPAD, environment, and direct proxy routes for supported clients. + +Higher-level retry, SSE, and request telemetry policy remains in `codex-client`. diff --git a/codex-rs/codex-client/src/bin/custom_ca_probe.rs b/codex-rs/http-client/src/bin/custom_ca_probe.rs similarity index 91% rename from codex-rs/codex-client/src/bin/custom_ca_probe.rs rename to codex-rs/http-client/src/bin/custom_ca_probe.rs index 81f5ba9bc2..c8db21510d 100644 --- a/codex-rs/codex-client/src/bin/custom_ca_probe.rs +++ b/codex-rs/http-client/src/bin/custom_ca_probe.rs @@ -10,9 +10,9 @@ //! - error messages guide users when CA files are invalid. //! - optional HTTPS probes can complete a request through the constructed client. //! -//! The detailed explanation of what "hermetic" means here lives in `codex_client::custom_ca`. +//! The detailed explanation of what "hermetic" means here lives in `codex_http_client::custom_ca`. //! This binary exists so the tests can exercise -//! [`codex_client::build_reqwest_client_for_subprocess_tests`] in a separate process without +//! [`codex_http_client::build_reqwest_client_for_subprocess_tests`] in a separate process without //! duplicating client-construction logic. use std::env; @@ -69,11 +69,11 @@ fn build_probe_client( if let Some(proxy_url) = proxy_url { let proxy = reqwest::Proxy::https(proxy_url) .map_err(|error| format!("failed to configure probe proxy {proxy_url}: {error}"))?; - return codex_client::build_reqwest_client_with_custom_ca(builder.proxy(proxy)) + return codex_http_client::build_reqwest_client_with_custom_ca(builder.proxy(proxy)) .map_err(|error| error.to_string()); } - codex_client::build_reqwest_client_for_subprocess_tests(builder) + codex_http_client::build_reqwest_client_for_subprocess_tests(builder) .map_err(|error| error.to_string()) } diff --git a/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs b/codex-rs/http-client/src/chatgpt_cloudflare_cookies.rs similarity index 98% rename from codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs rename to codex-rs/http-client/src/chatgpt_cloudflare_cookies.rs index 7e5d22057c..f63763dad4 100644 --- a/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs +++ b/codex-rs/http-client/src/chatgpt_cloudflare_cookies.rs @@ -7,7 +7,7 @@ use reqwest::header::HeaderValue; use crate::chatgpt_hosts::is_allowed_chatgpt_host; -// WARNING: this store is process-global and may be shared across auth contexts. +// WARNING: this HTTP cookie store is process-global and may be shared across auth contexts. // It must only ever contain Cloudflare infrastructure cookies. Never extend this // store to persist ChatGPT account, session, auth, or other user-specific cookie // data. diff --git a/codex-rs/codex-client/src/chatgpt_hosts.rs b/codex-rs/http-client/src/chatgpt_hosts.rs similarity index 96% rename from codex-rs/codex-client/src/chatgpt_hosts.rs rename to codex-rs/http-client/src/chatgpt_hosts.rs index dd0b99589c..0426c8bb36 100644 --- a/codex-rs/codex-client/src/chatgpt_hosts.rs +++ b/codex-rs/http-client/src/chatgpt_hosts.rs @@ -1,5 +1,5 @@ /// Returns whether `host` is one of the ChatGPT hosts Codex is allowed to treat -/// as first-party ChatGPT traffic. +/// as first-party ChatGPT HTTP traffic. pub fn is_allowed_chatgpt_host(host: &str) -> bool { const EXACT_HOSTS: &[&str] = &["chatgpt.com", "chat.openai.com", "chatgpt-staging.com"]; const SUBDOMAIN_SUFFIXES: &[&str] = &[".chatgpt.com", ".chatgpt-staging.com"]; diff --git a/codex-rs/codex-client/src/custom_ca.rs b/codex-rs/http-client/src/custom_ca.rs similarity index 94% rename from codex-rs/codex-client/src/custom_ca.rs rename to codex-rs/http-client/src/custom_ca.rs index 1a211beedf..5a2e51c320 100644 --- a/codex-rs/codex-client/src/custom_ca.rs +++ b/codex-rs/http-client/src/custom_ca.rs @@ -1,4 +1,4 @@ -//! Custom CA handling for Codex outbound HTTP and websocket clients. +//! Custom CA handling shared by Codex outbound HTTP and websocket clients. //! //! Codex constructs outbound reqwest clients and secure websocket connections in a few crates, but //! they all need the same trust-store policy when enterprise proxies or gateways intercept TLS. @@ -198,6 +198,16 @@ pub fn maybe_build_rustls_client_config_with_custom_ca() maybe_build_rustls_client_config_with_env(&ProcessEnv) } +/// Builds a rustls client config using native roots and any configured Codex custom CA bundle. +/// +/// Unlike [`maybe_build_rustls_client_config_with_custom_ca`], this always returns a config. Use +/// this when the caller must perform TLS itself instead of delegating default configuration to a +/// transport library. +pub fn build_rustls_client_config_with_custom_ca() +-> Result, BuildCustomCaTransportError> { + build_rustls_client_config_with_env(&ProcessEnv) +} + /// Builds a reqwest client for spawned subprocess tests that exercise CA behavior. /// /// This is the test-only client-construction path used by the subprocess coverage in `tests/`. @@ -219,6 +229,19 @@ fn maybe_build_rustls_client_config_with_env( return Ok(None); }; + build_rustls_client_config(Some(&bundle)).map(Some) +} + +fn build_rustls_client_config_with_env( + env_source: &dyn EnvSource, +) -> Result, BuildCustomCaTransportError> { + let bundle = env_source.configured_ca_bundle(); + build_rustls_client_config(bundle.as_ref()) +} + +fn build_rustls_client_config( + bundle: Option<&ConfiguredCaBundle>, +) -> Result, BuildCustomCaTransportError> { ensure_rustls_crypto_provider(); // Start from the platform roots so websocket callers keep the same baseline trust behavior @@ -235,30 +258,32 @@ fn maybe_build_rustls_client_config_with_env( } let _ = root_store.add_parsable_certificates(certs); - let certificates = bundle.load_certificates()?; - for (idx, cert) in certificates.into_iter().enumerate() { - if let Err(source) = root_store.add(cert) { - warn!( - source_env = bundle.source_env, - ca_path = %bundle.path.display(), - certificate_index = idx + 1, - error = %source, - "failed to register CA certificate in rustls root store" - ); - return Err(BuildCustomCaTransportError::RegisterRustlsCertificate { - source_env: bundle.source_env, - path: bundle.path.clone(), - certificate_index: idx + 1, - source, - }); + if let Some(bundle) = bundle { + let certificates = bundle.load_certificates()?; + for (idx, cert) in certificates.into_iter().enumerate() { + if let Err(source) = root_store.add(cert) { + warn!( + source_env = bundle.source_env, + ca_path = %bundle.path.display(), + certificate_index = idx + 1, + error = %source, + "failed to register CA certificate in rustls root store" + ); + return Err(BuildCustomCaTransportError::RegisterRustlsCertificate { + source_env: bundle.source_env, + path: bundle.path.clone(), + certificate_index: idx + 1, + source, + }); + } } } - Ok(Some(Arc::new( + Ok(Arc::new( ClientConfig::builder() .with_root_certificates(root_store) .with_no_client_auth(), - ))) + )) } /// Builds a reqwest client using an injected environment source and reqwest builder. diff --git a/codex-rs/codex-client/src/default_client.rs b/codex-rs/http-client/src/default_client.rs similarity index 93% rename from codex-rs/codex-client/src/default_client.rs rename to codex-rs/http-client/src/default_client.rs index 56b3ce4b16..b4465e7f8c 100644 --- a/codex-rs/codex-client/src/default_client.rs +++ b/codex-rs/http-client/src/default_client.rs @@ -14,47 +14,47 @@ use tracing::Span; use tracing_opentelemetry::OpenTelemetrySpanExt; #[derive(Clone, Debug)] -pub struct CodexHttpClient { +pub struct HttpClient { inner: reqwest::Client, } -impl CodexHttpClient { +impl HttpClient { pub fn new(inner: reqwest::Client) -> Self { Self { inner } } - pub fn get(&self, url: U) -> CodexRequestBuilder + pub fn get(&self, url: U) -> RequestBuilder where U: IntoUrl, { self.request(Method::GET, url) } - pub fn post(&self, url: U) -> CodexRequestBuilder + pub fn post(&self, url: U) -> RequestBuilder where U: IntoUrl, { self.request(Method::POST, url) } - pub fn request(&self, method: Method, url: U) -> CodexRequestBuilder + pub fn request(&self, method: Method, url: U) -> RequestBuilder where U: IntoUrl, { let url_str = url.as_str().to_string(); - CodexRequestBuilder::new(self.inner.request(method.clone(), url), method, url_str) + RequestBuilder::new(self.inner.request(method.clone(), url), method, url_str) } } #[must_use = "requests are not sent unless `send` is awaited"] #[derive(Debug)] -pub struct CodexRequestBuilder { +pub struct RequestBuilder { builder: reqwest::RequestBuilder, method: Method, url: String, } -impl CodexRequestBuilder { +impl RequestBuilder { fn new(builder: reqwest::RequestBuilder, method: Method, url: String) -> Self { Self { builder, diff --git a/codex-rs/codex-client/src/error.rs b/codex-rs/http-client/src/error.rs similarity index 91% rename from codex-rs/codex-client/src/error.rs rename to codex-rs/http-client/src/error.rs index fa2bfb4f79..6b49229026 100644 --- a/codex-rs/codex-client/src/error.rs +++ b/codex-rs/http-client/src/error.rs @@ -1,3 +1,5 @@ +//! Errors returned by the shared Codex HTTP transport. + use http::HeaderMap; use http::StatusCode; use thiserror::Error; diff --git a/codex-rs/http-client/src/lib.rs b/codex-rs/http-client/src/lib.rs new file mode 100644 index 0000000000..49fc4225c3 --- /dev/null +++ b/codex-rs/http-client/src/lib.rs @@ -0,0 +1,42 @@ +mod chatgpt_cloudflare_cookies; +mod chatgpt_hosts; +mod custom_ca; +mod default_client; +mod error; +mod outbound_proxy; +mod request; +mod transport; + +pub use crate::chatgpt_cloudflare_cookies::with_chatgpt_cloudflare_cookie_store; +pub use crate::chatgpt_hosts::is_allowed_chatgpt_host; +pub use crate::custom_ca::BuildCustomCaTransportError; +/// Test-only subprocess hook for custom CA coverage. +/// +/// This stays public only so the `custom_ca_probe` binary target can reuse the shared helper. It +/// is hidden from normal docs because ordinary callers should use +/// [`build_reqwest_client_with_custom_ca`] instead. +#[doc(hidden)] +pub use crate::custom_ca::build_reqwest_client_for_subprocess_tests; +pub use crate::custom_ca::build_reqwest_client_with_custom_ca; +pub use crate::custom_ca::build_rustls_client_config_with_custom_ca; +pub use crate::custom_ca::maybe_build_rustls_client_config_with_custom_ca; +pub use crate::default_client::HttpClient; +pub use crate::default_client::RequestBuilder; +pub use crate::error::StreamError; +pub use crate::error::TransportError; +pub use crate::outbound_proxy::BuildRouteAwareHttpClientError; +pub use crate::outbound_proxy::ClientRouteClass; +pub use crate::outbound_proxy::HttpClientFactory; +pub use crate::outbound_proxy::OutboundProxyPolicy; +pub use crate::outbound_proxy::OutboundProxyRoute; +pub use crate::outbound_proxy::RouteFailureClass; +pub use crate::request::EncodedJsonBody; +pub use crate::request::PreparedRequestBody; +pub use crate::request::Request; +pub use crate::request::RequestBody; +pub use crate::request::RequestCompression; +pub use crate::request::Response; +pub use crate::transport::ByteStream; +pub use crate::transport::HttpTransport; +pub use crate::transport::ReqwestTransport; +pub use crate::transport::StreamResponse; diff --git a/codex-rs/codex-client/src/outbound_proxy.rs b/codex-rs/http-client/src/outbound_proxy.rs similarity index 78% rename from codex-rs/codex-client/src/outbound_proxy.rs rename to codex-rs/http-client/src/outbound_proxy.rs index ea6587052e..71793025ed 100644 --- a/codex-rs/codex-client/src/outbound_proxy.rs +++ b/codex-rs/http-client/src/outbound_proxy.rs @@ -1,9 +1,10 @@ -//! Conservative outbound proxy selection for resolver-aware clients. +//! Conservative outbound proxy selection for resolver-aware HTTP clients. //! //! When enabled, platform system discovery is tried first, explicit environment //! proxies are the fallback, and the final fallback is a direct connection. //! When disabled, callers retain the existing reqwest builder behavior. +use std::borrow::Cow; use std::collections::HashMap; use std::fmt; use std::io; @@ -84,14 +85,123 @@ impl fmt::Display for RouteFailureClass { } } -/// Marker enabling fixed system/PAC/WPAD, environment, then direct routing. -/// Resolved endpoints and platform details remain internal to the client builder. +/// Resolved outbound proxy behavior for HTTP clients. +/// +/// Callers must choose a policy explicitly so omitting feature resolution cannot silently select +/// legacy behavior. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct OutboundProxyConfig; +pub enum OutboundProxyPolicy { + /// Preserve reqwest's built-in proxy behavior. + ReqwestDefault, + /// Resolve system/PAC/WPAD settings, then environment settings, then direct routing. + RespectSystemProxy, +} -impl OutboundProxyConfig { - pub const fn respect_system_proxy() -> Self { - Self +/// Resolved proxy route for a concrete outbound destination. +/// +/// `TransportDefault` delegates environment-proxy handling to the underlying transport. Proxy +/// URLs are intentionally redacted from `Debug` output because they may contain credentials. +#[derive(Clone, PartialEq, Eq)] +pub enum OutboundProxyRoute { + /// Preserve the underlying transport's existing proxy behavior. + TransportDefault, + /// Connect directly and bypass transport-level proxy discovery. + Direct, + /// Connect through the selected proxy URL. + Proxy { url: String }, +} + +impl fmt::Debug for OutboundProxyRoute { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TransportDefault => f.write_str("TransportDefault"), + Self::Direct => f.write_str("Direct"), + Self::Proxy { .. } => f.debug_struct("Proxy").field("url", &"").finish(), + } + } +} + +/// Builds route-specific HTTP clients using one resolved outbound proxy policy. +/// +/// Construct this once from the effective application configuration and carry it with the +/// session or component that owns outbound requests. Individual request paths should supply only +/// their destination and route class rather than resolving feature state themselves. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HttpClientFactory { + outbound_proxy_policy: OutboundProxyPolicy, +} + +impl HttpClientFactory { + /// Creates a factory from the outbound proxy policy resolved by the application. + pub const fn new(outbound_proxy_policy: OutboundProxyPolicy) -> Self { + Self { + outbound_proxy_policy, + } + } + + /// Returns the outbound proxy policy used for clients built by this factory. + pub const fn outbound_proxy_policy(&self) -> OutboundProxyPolicy { + self.outbound_proxy_policy + } + + /// Resolves the proxy route for a concrete destination. + /// + /// WebSocket schemes are resolved through their HTTP equivalents so platform PAC and system + /// proxy APIs apply the same policy to `ws`/`wss` and `http`/`https` destinations. When system + /// resolution is unavailable, the transport retains responsibility for environment-proxy + /// fallback. + pub fn resolve_proxy_route(&self, request_url: &str) -> OutboundProxyRoute { + resolve_proxy_route( + request_url, + self.outbound_proxy_policy, + resolve_system_proxy, + ) + } + + /// Builds a reqwest client for a concrete outbound route. + pub fn build_reqwest_client( + &self, + builder: reqwest::ClientBuilder, + request_url: &str, + route_class: ClientRouteClass, + ) -> Result { + build_reqwest_client_for_route( + builder, + request_url, + route_class, + self.outbound_proxy_policy, + ) + } +} + +fn resolve_proxy_route( + request_url: &str, + outbound_proxy_policy: OutboundProxyPolicy, + resolve_system_proxy: impl FnOnce(&str, &RequestOrigin) -> SystemProxyDecision, +) -> OutboundProxyRoute { + if matches!(outbound_proxy_policy, OutboundProxyPolicy::ReqwestDefault) { + return OutboundProxyRoute::TransportDefault; + } + + let request_url = proxy_resolution_url(request_url); + let Some(origin) = RequestOrigin::parse(&request_url) else { + return OutboundProxyRoute::TransportDefault; + }; + + match resolve_system_proxy(&request_url, &origin) { + SystemProxyDecision::Direct => OutboundProxyRoute::Direct, + SystemProxyDecision::Proxy { url } => OutboundProxyRoute::Proxy { url }, + SystemProxyDecision::Unavailable { .. } => OutboundProxyRoute::TransportDefault, + } +} + +fn proxy_resolution_url(request_url: &str) -> Cow<'_, str> { + if let Some(suffix) = request_url.strip_prefix("wss://") { + Cow::Owned(format!("https://{suffix}")) + } else if let Some(suffix) = request_url.strip_prefix("ws://") { + Cow::Owned(format!("http://{suffix}")) + } else { + Cow::Borrowed(request_url) } } @@ -120,18 +230,18 @@ impl From for io::Error { /// a route is selected are returned without trying another route. Ordered PAC candidates are /// currently collapsed to one route on both Windows and macOS; later proxy or `DIRECT` candidates /// are not retried after a connection failure. -pub fn build_reqwest_client_for_route( +fn build_reqwest_client_for_route( builder: reqwest::ClientBuilder, request_url: &str, route_class: ClientRouteClass, - config: Option<&OutboundProxyConfig>, + outbound_proxy_policy: OutboundProxyPolicy, ) -> Result { let builder = configure_proxy_for_route( &ProcessEnv, builder, request_url, route_class, - config, + outbound_proxy_policy, resolve_system_proxy, )?; build_reqwest_client_with_custom_ca(builder).map_err(Into::into) @@ -142,10 +252,10 @@ fn configure_proxy_for_route( builder: reqwest::ClientBuilder, request_url: &str, route_class: ClientRouteClass, - config: Option<&OutboundProxyConfig>, + outbound_proxy_policy: OutboundProxyPolicy, resolve_system_proxy: impl FnOnce(&str, &RequestOrigin) -> SystemProxyDecision, ) -> Result { - if config.is_none() { + if matches!(outbound_proxy_policy, OutboundProxyPolicy::ReqwestDefault) { return Ok(builder); } let origin = RequestOrigin::parse(request_url); @@ -219,8 +329,8 @@ impl RequestOrigin { let scheme = uri.scheme_str()?.to_ascii_lowercase(); let host = uri.host()?.trim_matches(['[', ']']).to_ascii_lowercase(); let port = uri.port_u16().or(match scheme.as_str() { - "http" => Some(80), - "https" => Some(443), + "http" | "ws" => Some(80), + "https" | "wss" => Some(443), _ => None, })?; Some(Self { scheme, host, port }) diff --git a/codex-rs/codex-client/src/outbound_proxy/macos.rs b/codex-rs/http-client/src/outbound_proxy/macos.rs similarity index 99% rename from codex-rs/codex-client/src/outbound_proxy/macos.rs rename to codex-rs/http-client/src/outbound_proxy/macos.rs index 3913ecc40b..fe07a6b498 100644 --- a/codex-rs/codex-client/src/outbound_proxy/macos.rs +++ b/codex-rs/http-client/src/outbound_proxy/macos.rs @@ -1,3 +1,5 @@ +//! macOS system proxy resolution through SystemConfiguration and CFNetwork. + use std::ffi::c_void; use std::ptr; use std::time::Duration; diff --git a/codex-rs/codex-client/src/outbound_proxy/windows.rs b/codex-rs/http-client/src/outbound_proxy/windows.rs similarity index 99% rename from codex-rs/codex-client/src/outbound_proxy/windows.rs rename to codex-rs/http-client/src/outbound_proxy/windows.rs index 9e64196705..4ff927723a 100644 --- a/codex-rs/codex-client/src/outbound_proxy/windows.rs +++ b/codex-rs/http-client/src/outbound_proxy/windows.rs @@ -1,3 +1,5 @@ +//! Windows system proxy resolution through WinHTTP. + use std::ffi::c_void; use std::ptr; diff --git a/codex-rs/codex-client/src/outbound_proxy/windows_tests.rs b/codex-rs/http-client/src/outbound_proxy/windows_tests.rs similarity index 93% rename from codex-rs/codex-client/src/outbound_proxy/windows_tests.rs rename to codex-rs/http-client/src/outbound_proxy/windows_tests.rs index 3772c057db..589c06661c 100644 --- a/codex-rs/codex-client/src/outbound_proxy/windows_tests.rs +++ b/codex-rs/http-client/src/outbound_proxy/windows_tests.rs @@ -1,3 +1,5 @@ +//! Windows proxy parsing tests. + use super::*; #[test] diff --git a/codex-rs/codex-client/src/outbound_proxy_tests.rs b/codex-rs/http-client/src/outbound_proxy_tests.rs similarity index 85% rename from codex-rs/codex-client/src/outbound_proxy_tests.rs rename to codex-rs/http-client/src/outbound_proxy_tests.rs index 46868e8f73..3f8bd925c9 100644 --- a/codex-rs/codex-client/src/outbound_proxy_tests.rs +++ b/codex-rs/http-client/src/outbound_proxy_tests.rs @@ -1,3 +1,5 @@ +//! Shared outbound proxy policy tests. + use super::*; use pretty_assertions::assert_eq; use std::io::Read; @@ -7,6 +9,41 @@ struct MapEnv { values: HashMap, } +#[test] +fn websocket_route_uses_http_equivalent_for_system_resolution() { + let route = resolve_proxy_route( + "wss://api.openai.com/v1/responses", + OutboundProxyPolicy::RespectSystemProxy, + |request_url, origin| { + assert_eq!(request_url, "https://api.openai.com/v1/responses"); + assert_eq!(origin.scheme, "https"); + assert_eq!(origin.host, "api.openai.com"); + assert_eq!(origin.port, 443); + SystemProxyDecision::Proxy { + url: "http://proxy.example:8080".to_string(), + } + }, + ); + + assert_eq!( + route, + OutboundProxyRoute::Proxy { + url: "http://proxy.example:8080".to_string(), + } + ); +} + +#[test] +fn reqwest_default_route_preserves_transport_proxy_behavior() { + let route = resolve_proxy_route( + "wss://api.openai.com/v1/responses", + OutboundProxyPolicy::ReqwestDefault, + |_, _| panic!("default policy should not resolve system proxy settings"), + ); + + assert_eq!(route, OutboundProxyRoute::TransportDefault); +} + impl EnvSource for MapEnv { fn var(&self, key: &str) -> Option { self.values.get(key).cloned() @@ -77,13 +114,12 @@ async fn enabled_environment_proxy_routes_request_through_proxy() { values: HashMap::from([("HTTP_PROXY".to_string(), format!("http://{proxy_addr}"))]), }; let request_url = "http://enabled-proxy.test/proxy-check"; - let config = OutboundProxyConfig::respect_system_proxy(); let builder = configure_proxy_for_route( &env, reqwest::Client::builder().timeout(Duration::from_secs(2)), request_url, ClientRouteClass::Auth, - Some(&config), + OutboundProxyPolicy::RespectSystemProxy, |_, _| SystemProxyDecision::Unavailable { failure: RouteFailureClass::ProxyResolutionUnavailable, }, diff --git a/codex-rs/codex-client/src/request.rs b/codex-rs/http-client/src/request.rs similarity index 98% rename from codex-rs/codex-client/src/request.rs rename to codex-rs/http-client/src/request.rs index 6d50f7cb57..7608860c3d 100644 --- a/codex-rs/codex-client/src/request.rs +++ b/codex-rs/http-client/src/request.rs @@ -1,7 +1,7 @@ use bytes::Bytes; +use http::HeaderMap; +use http::HeaderValue; use http::Method; -use reqwest::header::HeaderMap; -use reqwest::header::HeaderValue; use serde::Serialize; use serde_json::Value; use std::time::Duration; @@ -121,7 +121,7 @@ impl Request { Some(RequestBody::Json(_) | RequestBody::EncodedJson(_)) ); let trace_bytes = if self.compression != RequestCompression::None - && tracing::enabled!(target: "codex_client::transport", tracing::Level::TRACE) + && tracing::enabled!(target: "codex_http_client::transport", tracing::Level::TRACE) { match self.body.as_ref() { Some(RequestBody::Json(body)) => Some(Bytes::from( diff --git a/codex-rs/codex-client/src/transport.rs b/codex-rs/http-client/src/transport.rs similarity index 94% rename from codex-rs/codex-client/src/transport.rs rename to codex-rs/http-client/src/transport.rs index 708b73bf2b..bda6c28ee9 100644 --- a/codex-rs/codex-client/src/transport.rs +++ b/codex-rs/http-client/src/transport.rs @@ -1,5 +1,5 @@ -use crate::default_client::CodexHttpClient; -use crate::default_client::CodexRequestBuilder; +use crate::default_client::HttpClient; +use crate::default_client::RequestBuilder; use crate::error::TransportError; use crate::request::Request; use crate::request::RequestBody; @@ -35,17 +35,17 @@ pub trait HttpTransport: Send + Sync { #[derive(Clone, Debug)] pub struct ReqwestTransport { - client: CodexHttpClient, + client: HttpClient, } impl ReqwestTransport { pub fn new(client: reqwest::Client) -> Self { Self { - client: CodexHttpClient::new(client), + client: HttpClient::new(client), } } - fn build(&self, req: Request) -> Result { + fn build(&self, req: Request) -> Result { let prepared = req.prepare_body_for_send().map_err(TransportError::Build)?; let Request { diff --git a/codex-rs/codex-client/tests/ca_env.rs b/codex-rs/http-client/tests/ca_env.rs similarity index 99% rename from codex-rs/codex-client/tests/ca_env.rs rename to codex-rs/http-client/tests/ca_env.rs index 7ddd138a33..f35225fee7 100644 --- a/codex-rs/codex-client/tests/ca_env.rs +++ b/codex-rs/http-client/tests/ca_env.rs @@ -3,7 +3,7 @@ //! //! These tests intentionally run through `custom_ca_probe` and //! `build_reqwest_client_for_subprocess_tests` instead of calling the helper in-process. The -//! detailed explanation of what "hermetic" means here lives in `codex_client::custom_ca`; these +//! detailed explanation of what "hermetic" means here lives in `codex_http_client::custom_ca`; these //! tests add the process-level half of that contract by scrubbing inherited CA environment //! variables before each subprocess launch. Most assertions here cover CA file selection, PEM //! parsing, and user-facing errors. The HTTPS probes go further and perform real POSTs against diff --git a/codex-rs/codex-client/tests/fixtures/test-ca-trusted.pem b/codex-rs/http-client/tests/fixtures/test-ca-trusted.pem similarity index 100% rename from codex-rs/codex-client/tests/fixtures/test-ca-trusted.pem rename to codex-rs/http-client/tests/fixtures/test-ca-trusted.pem diff --git a/codex-rs/codex-client/tests/fixtures/test-ca.pem b/codex-rs/http-client/tests/fixtures/test-ca.pem similarity index 100% rename from codex-rs/codex-client/tests/fixtures/test-ca.pem rename to codex-rs/http-client/tests/fixtures/test-ca.pem diff --git a/codex-rs/codex-client/tests/fixtures/test-intermediate.pem b/codex-rs/http-client/tests/fixtures/test-intermediate.pem similarity index 100% rename from codex-rs/codex-client/tests/fixtures/test-intermediate.pem rename to codex-rs/http-client/tests/fixtures/test-intermediate.pem diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index fc7937536c..0f7c68a152 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -19,6 +19,7 @@ workspace = true [target.'cfg(target_os = "linux")'.dependencies] clap = { workspace = true, features = ["derive"] } codex-install-context = { workspace = true } +codex-network-proxy = { workspace = true } codex-process-hardening = { workspace = true } codex-protocol = { workspace = true } codex-sandboxing = { workspace = true } diff --git a/codex-rs/linux-sandbox/src/proxy_routing.rs b/codex-rs/linux-sandbox/src/proxy_routing.rs index f171d91745..707cbaafbe 100644 --- a/codex-rs/linux-sandbox/src/proxy_routing.rs +++ b/codex-rs/linux-sandbox/src/proxy_routing.rs @@ -1,3 +1,5 @@ +use codex_network_proxy::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; +use codex_network_proxy::write_attribution_frame; use serde::Deserialize; use serde::Serialize; use std::collections::BTreeMap; @@ -71,8 +73,12 @@ struct ProxyRoutePlan { } pub(crate) fn prepare_host_proxy_route_spec() -> io::Result { - let env: HashMap = std::env::vars().collect(); - let plan = plan_proxy_routes(&env); + let (attribution_token, plan) = extract_attribution_token_and_plan(std::env::vars().collect()); + // SAFETY: the sandbox helper is single-threaded here, before it forks bridge workers or + // executes the user command. + unsafe { + std::env::remove_var(PROXY_ATTRIBUTION_TOKEN_ENV_KEY); + } if plan.routes.is_empty() { let message = if plan.has_proxy_config { @@ -100,7 +106,11 @@ pub(crate) fn prepare_host_proxy_route_spec() -> io::Result { let mut host_bridge_pids = Vec::with_capacity(socket_by_endpoint.len()); for (endpoint, socket_path) in &socket_by_endpoint { - host_bridge_pids.push(spawn_host_bridge(*endpoint, socket_path)?); + host_bridge_pids.push(spawn_host_bridge( + *endpoint, + socket_path, + attribution_token.as_deref(), + )?); } spawn_proxy_socket_dir_cleanup_worker(socket_dir, host_bridge_pids)?; @@ -121,6 +131,14 @@ pub(crate) fn prepare_host_proxy_route_spec() -> io::Result { serde_json::to_string(&ProxyRouteSpec { routes }).map_err(io::Error::other) } +fn extract_attribution_token_and_plan( + mut env: HashMap, +) -> (Option, ProxyRoutePlan) { + let attribution_token = env.remove(PROXY_ATTRIBUTION_TOKEN_ENV_KEY); + let plan = plan_proxy_routes(&env); + (attribution_token, plan) +} + pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Result<()> { let spec: ProxyRouteSpec = serde_json::from_str(serialized_spec).map_err(io::Error::other)?; @@ -438,7 +456,11 @@ fn cleanup_proxy_socket_dir(socket_dir: &Path) -> io::Result<()> { } } -fn spawn_host_bridge(endpoint: SocketAddr, uds_path: &Path) -> io::Result { +fn spawn_host_bridge( + endpoint: SocketAddr, + uds_path: &Path, + attribution_token: Option<&str>, +) -> io::Result { let (read_fd, write_fd) = create_ready_pipe()?; let pid = unsafe { libc::fork() }; if pid < 0 { @@ -452,7 +474,7 @@ fn spawn_host_bridge(endpoint: SocketAddr, uds_path: &Path) -> io::Result io::Result io::Result<()> { +fn run_host_bridge( + endpoint: SocketAddr, + uds_path: &Path, + ready_fd: libc::c_int, + attribution_token: Option<&str>, +) -> io::Result<()> { harden_bridge_process()?; if uds_path.exists() { std::fs::remove_file(uds_path)?; @@ -482,13 +509,22 @@ fn run_host_bridge(endpoint: SocketAddr, uds_path: &Path, ready_fd: libc::c_int) ready_file.write_all(&[HOST_BRIDGE_READY])?; drop(ready_file); + let attribution_token = attribution_token.map(str::to_owned); loop { let (unix_stream, _) = listener.accept()?; + let attribution_token = attribution_token.clone(); std::thread::spawn(move || { - let tcp_stream = match TcpStream::connect(endpoint) { + let mut tcp_stream = match TcpStream::connect(endpoint) { Ok(stream) => stream, Err(_) => return, }; + if let Some(attribution_token) = attribution_token + && write_attribution_frame(&mut tcp_stream, &attribution_token).is_err() + { + // The shared ingress must reject unauthenticated connections; do not forward + // application bytes if this bridge cannot prove the exec attribution first. + return; + } let _ = proxy_bidirectional(tcp_stream, unix_stream); }); } @@ -673,12 +709,14 @@ fn close_fd(fd: libc::c_int) -> io::Result<()> { #[cfg(test)] mod tests { + use super::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; use super::PROXY_SOCKET_DIR_PREFIX; use super::ProxyRouteEntry; use super::ProxyRouteSpec; use super::cleanup_proxy_socket_dir; use super::cleanup_stale_proxy_socket_dirs_in; use super::default_proxy_port; + use super::extract_attribution_token_and_plan; use super::is_proxy_env_key; use super::parse_loopback_proxy_endpoint; use super::parse_proxy_socket_dir_owner_pid; @@ -743,6 +781,35 @@ mod tests { ); } + #[test] + fn attribution_token_is_extracted_before_proxy_route_planning() { + let mut env = HashMap::new(); + env.insert( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:43128".to_string(), + ); + env.insert( + PROXY_ATTRIBUTION_TOKEN_ENV_KEY.to_string(), + "exec-token".to_string(), + ); + + let (attribution_token, plan) = extract_attribution_token_and_plan(env); + + assert_eq!(attribution_token.as_deref(), Some("exec-token")); + assert_eq!( + plan, + super::ProxyRoutePlan { + routes: vec![super::PlannedProxyRoute { + env_key: "HTTP_PROXY".to_string(), + endpoint: "127.0.0.1:43128" + .parse::() + .expect("valid socket"), + }], + has_proxy_config: true, + } + ); + } + #[test] fn rewrites_proxy_url_to_local_loopback_port() { let rewritten = diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index 04066f5894..3eb253d7dc 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -11,7 +11,7 @@ workspace = true base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } codex-agent-identity = { workspace = true } -codex-client = { workspace = true } +codex-http-client = { workspace = true } codex-config = { workspace = true } codex-keyring-store = { workspace = true } codex-model-provider-info = { workspace = true } diff --git a/codex-rs/login/src/auth/auth_headers.rs b/codex-rs/login/src/auth/auth_headers.rs new file mode 100644 index 0000000000..802f00c4d3 --- /dev/null +++ b/codex-rs/login/src/auth/auth_headers.rs @@ -0,0 +1,30 @@ +use std::fmt; + +use reqwest::header::HeaderMap; + +/// Request headers returned by an external auth provider. +/// +/// The provider owns credential validation, rotation, and persistence. Codex +/// keeps the resolved headers in memory and attaches them to backend requests. +#[derive(Clone, PartialEq, Eq)] +pub struct AuthHeaders { + headers: HeaderMap, +} + +impl AuthHeaders { + pub fn new(headers: HeaderMap) -> Self { + Self { headers } + } + + pub fn headers(&self) -> &HeaderMap { + &self.headers + } +} + +impl fmt::Debug for AuthHeaders { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AuthHeaders") + .field("headers", &"") + .finish() + } +} diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 9509cab456..780cd86009 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -1015,19 +1015,6 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { assert_eq!(manager.refresh_failure_for_auth(&updated_auth), None); } -#[test] -fn external_auth_tokens_without_chatgpt_metadata_cannot_seed_chatgpt_auth() { - let err = AuthDotJson::from_external_tokens(&ExternalAuthTokens::access_token_only( - "test-access-token", - )) - .expect_err("bearer-only external auth should not seed ChatGPT auth"); - - assert_eq!( - err.to_string(), - "external auth tokens are missing ChatGPT metadata" - ); -} - #[tokio::test] async fn external_bearer_only_auth_manager_uses_cached_provider_token() { let script = ProviderAuthScript::new(&["provider-token", "next-token"]).unwrap(); @@ -1082,11 +1069,11 @@ async fn unauthorized_recovery_uses_external_refresh_for_bearer_manager() { let mut auth_config = script.auth_config(); auth_config.refresh_interval_ms = 0; let manager = AuthManager::external_bearer_only(auth_config); + let mut recovery = manager.unauthorized_recovery(); let initial_token = manager .auth() .await .and_then(|auth| auth.api_key().map(str::to_string)); - let mut recovery = manager.unauthorized_recovery(); assert!(recovery.has_next()); assert_eq!(recovery.mode_name(), "external"); @@ -1106,6 +1093,61 @@ async fn unauthorized_recovery_uses_external_refresh_for_bearer_manager() { assert_eq!(refreshed_token.as_deref(), Some("refreshed-provider-token")); } +#[derive(Clone)] +struct StaticExternalAuth(CodexAuth); + +impl ExternalAuth for StaticExternalAuth { + fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async { Ok(self.0.clone()) }) + } + + fn refresh(&self, _context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async { Ok(self.0.clone()) }) + } +} + +#[tokio::test] +async fn external_auth_provider_can_install_headers() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_static("Bearer external"), + ); + headers.insert( + "x-external-auth", + reqwest::header::HeaderValue::from_static("enabled"), + ); + let auth = CodexAuth::Headers(AuthHeaders::new(headers)); + let codex_home = tempdir().expect("tempdir"); + let manager = AuthManager::new( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::Ephemeral, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, + ) + .await; + + manager + .set_external_auth(Arc::new(StaticExternalAuth(auth.clone()))) + .await + .expect("external auth should install"); + + assert_eq!(manager.auth_cached(), Some(auth)); + assert!( + manager + .auth_cached() + .is_some_and(|auth| auth.uses_codex_backend()) + ); + assert!( + !manager + .auth_cached() + .is_some_and(|auth| auth.is_chatgpt_auth()) + ); +} + struct ProviderAuthScript { tempdir: TempDir, command: String, diff --git a/codex-rs/login/src/auth/bedrock_api_key_tests.rs b/codex-rs/login/src/auth/bedrock_api_key_tests.rs index 1f8fab68b2..182834af66 100644 --- a/codex-rs/login/src/auth/bedrock_api_key_tests.rs +++ b/codex-rs/login/src/auth/bedrock_api_key_tests.rs @@ -85,6 +85,7 @@ async fn login_with_bedrock_api_key_replaces_openai_auth() -> anyhow::Result<()> CodexAuth::ApiKey(_) | CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::Headers(_) | CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => None, }), @@ -148,6 +149,7 @@ async fn bedrock_only_auth_storage_creates_primary_auth() -> anyhow::Result<()> CodexAuth::ApiKey(_) | CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::Headers(_) | CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => None, }), diff --git a/codex-rs/login/src/auth/default_client.rs b/codex-rs/login/src/auth/default_client.rs index f72727bfca..089e78340b 100644 --- a/codex-rs/login/src/auth/default_client.rs +++ b/codex-rs/login/src/auth/default_client.rs @@ -1,17 +1,18 @@ //! Default Codex HTTP client: shared `User-Agent`, `originator`, optional residency header, and -//! reqwest/`CodexHttpClient` construction. +//! reqwest/`HttpClient` construction. //! //! Use [`crate::default_client`] or [`codex_login::default_client`] from other crates in this //! workspace. -use codex_client::BuildCustomCaTransportError; -use codex_client::BuildRouteAwareHttpClientError; -use codex_client::ClientRouteClass; -use codex_client::CodexHttpClient; -pub use codex_client::CodexRequestBuilder; -use codex_client::build_reqwest_client_for_route; -use codex_client::build_reqwest_client_with_custom_ca; -use codex_client::with_chatgpt_cloudflare_cookie_store; +use codex_http_client::BuildCustomCaTransportError; +use codex_http_client::BuildRouteAwareHttpClientError; +use codex_http_client::ClientRouteClass; +use codex_http_client::HttpClient; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; +pub use codex_http_client::RequestBuilder as CodexRequestBuilder; +use codex_http_client::build_reqwest_client_with_custom_ca; +use codex_http_client::with_chatgpt_cloudflare_cookie_store; use codex_terminal_detection::user_agent; use reqwest::header::HeaderMap; use reqwest::header::HeaderValue; @@ -197,9 +198,9 @@ fn sanitize_user_agent(candidate: String, fallback: &str) -> String { /// /// This supported default path preserves reqwest's existing proxy behavior and does not opt into /// Codex's route-aware system/PAC resolution. -pub fn create_client() -> CodexHttpClient { +pub fn create_client() -> HttpClient { let inner = build_reqwest_client(); - CodexHttpClient::new(inner) + HttpClient::new(inner) } /// Builds the default reqwest client used for ordinary Codex HTTP traffic. @@ -235,6 +236,35 @@ pub fn try_build_reqwest_client() -> Result Result { + if matches!( + http_client_factory.outbound_proxy_policy(), + OutboundProxyPolicy::ReqwestDefault + ) { + return Ok(build_reqwest_client()); + } + if is_sandboxed() { + // Preserve the sandbox's existing no-proxy policy; sandboxed command egress is routed + // separately through network-proxy. + return Ok(build_reqwest_client()); + } + + http_client_factory.build_reqwest_client( + default_reqwest_client_builder(), + request_url, + route_class, + ) +} + fn default_reqwest_client_builder() -> reqwest::ClientBuilder { let mut builder = reqwest::Client::builder().default_headers(default_headers()); if is_sandboxed() { @@ -248,11 +278,10 @@ pub(crate) fn build_raw_auth_reqwest_client( endpoint: &str, auth_route_config: Option<&AuthRouteConfig>, ) -> Result { - build_reqwest_client_for_route( + auth_http_client_factory(auth_route_config).build_reqwest_client( reqwest::Client::builder(), endpoint, ClientRouteClass::Auth, - auth_route_config.map(AuthRouteConfig::route_config), ) } @@ -261,20 +290,10 @@ pub(crate) fn build_default_auth_reqwest_client( endpoint: &str, auth_route_config: Option<&AuthRouteConfig>, ) -> Result { - let Some(route_config) = auth_route_config.map(AuthRouteConfig::route_config) else { - return Ok(build_reqwest_client()); - }; - - if is_sandboxed() { - // Preserve the sandbox's existing no-proxy policy; sandboxed command egress is routed - // separately through network-proxy. - return Ok(build_reqwest_client()); - } - build_reqwest_client_for_route( - default_reqwest_client_builder(), + build_default_reqwest_client_for_route( + &auth_http_client_factory(auth_route_config), endpoint, ClientRouteClass::Auth, - Some(route_config), ) } @@ -282,8 +301,15 @@ pub(crate) fn build_default_auth_reqwest_client( pub(crate) fn create_default_auth_client( endpoint: &str, auth_route_config: Option<&AuthRouteConfig>, -) -> Result { - build_default_auth_reqwest_client(endpoint, auth_route_config).map(CodexHttpClient::new) +) -> Result { + build_default_auth_reqwest_client(endpoint, auth_route_config).map(HttpClient::new) +} + +fn auth_http_client_factory(auth_route_config: Option<&AuthRouteConfig>) -> HttpClientFactory { + auth_route_config.map_or_else( + || HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + |config| config.http_client_factory().clone(), + ) } pub fn default_headers() -> HeaderMap { diff --git a/codex-rs/login/src/auth/external_bearer.rs b/codex-rs/login/src/auth/external_bearer.rs index f30d52f434..0a276543ea 100644 --- a/codex-rs/login/src/auth/external_bearer.rs +++ b/codex-rs/login/src/auth/external_bearer.rs @@ -1,8 +1,7 @@ +use super::manager::CodexAuth; use super::manager::ExternalAuth; use super::manager::ExternalAuthFuture; use super::manager::ExternalAuthRefreshContext; -use super::manager::ExternalAuthTokens; -use codex_protocol::auth::AuthMode; use codex_protocol::config_types::ModelProviderAuthInfo; use std::fmt; use std::io; @@ -30,7 +29,7 @@ impl BearerTokenRefresher { clippy::await_holding_invalid_type, reason = "external bearer cache misses intentionally hold cached_token across the provider command to avoid duplicate refreshes" )] - async fn resolve(&self) -> io::Result> { + async fn resolve(&self) -> io::Result { let access_token = { let mut cached = self.state.cached_token.lock().await; if let Some(cached_token) = cached.as_ref() { @@ -39,9 +38,7 @@ impl BearerTokenRefresher { None => true, }; if should_use_cached_token { - return Ok(Some(ExternalAuthTokens::access_token_only( - cached_token.access_token.clone(), - ))); + return Ok(CodexAuth::from_api_key(cached_token.access_token.as_str())); } } @@ -52,36 +49,26 @@ impl BearerTokenRefresher { }); access_token }; - Ok(Some(ExternalAuthTokens::access_token_only(access_token))) + Ok(CodexAuth::from_api_key(access_token.as_str())) } - async fn refresh( - &self, - _context: ExternalAuthRefreshContext, - ) -> io::Result { + async fn refresh(&self, _context: ExternalAuthRefreshContext) -> io::Result { let access_token = run_provider_auth_command(&self.state.config).await?; let mut cached = self.state.cached_token.lock().await; *cached = Some(CachedExternalBearerToken { access_token: access_token.clone(), fetched_at: Instant::now(), }); - Ok(ExternalAuthTokens::access_token_only(access_token)) + Ok(CodexAuth::from_api_key(access_token.as_str())) } } impl ExternalAuth for BearerTokenRefresher { - fn auth_mode(&self) -> AuthMode { - AuthMode::ApiKey - } - - fn resolve(&self) -> ExternalAuthFuture<'_, Option> { + fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> { Box::pin(BearerTokenRefresher::resolve(self)) } - fn refresh( - &self, - context: ExternalAuthRefreshContext, - ) -> ExternalAuthFuture<'_, ExternalAuthTokens> { + fn refresh(&self, context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> { Box::pin(BearerTokenRefresher::refresh(self, context)) } } diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 844410874d..9509efc348 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -38,6 +38,7 @@ use super::agent_identity::require_agent_identity_authapi_base_url; use super::agent_identity::verified_record_from_jwt; use super::external_bearer::BearerTokenRefresher; use super::revoke::revoke_auth_tokens; +use crate::auth::AuthHeaders; pub use crate::auth::agent_identity::AgentIdentityAuth; pub use crate::auth::agent_identity::AgentIdentityAuthError; pub use crate::auth::bedrock_api_key::BedrockApiKeyAuth; @@ -55,8 +56,8 @@ use crate::outbound_proxy::AuthRouteConfig; use crate::token_data::TokenData; use crate::token_data::parse_chatgpt_jwt_claims; use crate::token_data::parse_jwt_expiration; -use codex_client::CodexHttpClient; use codex_config::types::AuthCredentialsStoreMode; +use codex_http_client::HttpClient; use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::auth::PlanType as InternalPlanType; use codex_protocol::auth::RefreshTokenFailedError; @@ -71,6 +72,7 @@ pub enum CodexAuth { ApiKey(ApiKeyAuth), Chatgpt(ChatgptAuth), ChatgptAuthTokens(ChatgptAuthTokens), + Headers(AuthHeaders), AgentIdentity(AgentIdentityAuth), PersonalAccessToken(PersonalAccessTokenAuth), BedrockApiKey(BedrockApiKeyAuth), @@ -145,6 +147,7 @@ impl AgentIdentityBootstrapCooldown { impl PartialEq for CodexAuth { fn eq(&self, other: &Self) -> bool { match (self, other) { + (Self::Headers(a), Self::Headers(b)) => a == b, (Self::PersonalAccessToken(a), Self::PersonalAccessToken(b)) => a == b, (Self::BedrockApiKey(a), Self::BedrockApiKey(b)) => a == b, _ => self.api_auth_mode() == other.api_auth_mode(), @@ -171,7 +174,7 @@ pub struct ChatgptAuthTokens { #[derive(Debug, Clone)] struct ChatgptAuthState { auth_dot_json: Arc>>, - client: CodexHttpClient, + client: HttpClient, } const TOKEN_REFRESH_INTERVAL: i64 = 8; @@ -198,45 +201,6 @@ pub enum RefreshTokenError { Transient(#[from] std::io::Error), } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ExternalAuthTokens { - pub access_token: String, - pub chatgpt_metadata: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ExternalAuthChatgptMetadata { - pub account_id: String, - pub plan_type: Option, -} - -impl ExternalAuthTokens { - pub fn access_token_only(access_token: impl Into) -> Self { - Self { - access_token: access_token.into(), - chatgpt_metadata: None, - } - } - - pub fn chatgpt( - access_token: impl Into, - chatgpt_account_id: impl Into, - chatgpt_plan_type: Option, - ) -> Self { - Self { - access_token: access_token.into(), - chatgpt_metadata: Some(ExternalAuthChatgptMetadata { - account_id: chatgpt_account_id.into(), - plan_type: chatgpt_plan_type, - }), - } - } - - pub fn chatgpt_metadata(&self) -> Option<&ExternalAuthChatgptMetadata> { - self.chatgpt_metadata.as_ref() - } -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ExternalAuthRefreshReason { Unauthorized, @@ -250,23 +214,13 @@ pub struct ExternalAuthRefreshContext { /// Pluggable auth provider used by `AuthManager` for externally managed auth flows. /// -/// Implementations may either resolve auth eagerly via `resolve()` or provide refreshed -/// credentials on demand via `refresh()`. +/// Implementations own the current auth value and any source-specific refresh mechanism. pub trait ExternalAuth: Send + Sync { - /// Indicates which top-level auth mode this external provider supplies. - fn auth_mode(&self) -> AuthMode; + /// Returns the provider's current auth value. + fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth>; - /// Returns cached or immediately available auth, if this provider can resolve it synchronously - /// from the caller's perspective. - fn resolve(&self) -> ExternalAuthFuture<'_, Option> { - Box::pin(async { Ok(None) }) - } - - /// Refreshes auth in response to a manager-driven refresh attempt. - fn refresh( - &self, - context: ExternalAuthRefreshContext, - ) -> ExternalAuthFuture<'_, ExternalAuthTokens>; + /// Refreshes auth and makes the returned value current for future `resolve()` calls. + fn refresh(&self, context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth>; } pub type ExternalAuthFuture<'a, T> = Pin> + Send + 'a>>; @@ -357,6 +311,11 @@ impl CodexAuth { }; return Ok(Self::BedrockApiKey(auth)); } + if auth_mode == AuthMode::Headers { + return Err(std::io::Error::other( + "externally provided auth cannot be loaded from auth storage.", + )); + } let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); let client = create_default_auth_client(&refresh_token_endpoint(), auth_route_config)?; @@ -376,6 +335,9 @@ impl CodexAuth { } AuthMode::ChatgptAuthTokens => Ok(Self::ChatgptAuthTokens(ChatgptAuthTokens { state })), AuthMode::ApiKey => unreachable!("api key mode is handled above"), + AuthMode::Headers => { + unreachable!("externally provided auth is never loaded from auth storage") + } AuthMode::AgentIdentity => unreachable!("agent identity mode is handled above"), AuthMode::PersonalAccessToken => { unreachable!("personal access token mode is handled above") @@ -458,6 +420,7 @@ impl CodexAuth { match self { Self::ApiKey(_) => AuthMode::ApiKey, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt, + Self::Headers(_) => AuthMode::Headers, Self::AgentIdentity(_) => AuthMode::AgentIdentity, Self::PersonalAccessToken(_) => AuthMode::PersonalAccessToken, Self::BedrockApiKey(_) => AuthMode::BedrockApiKey, @@ -470,6 +433,7 @@ impl CodexAuth { Self::ApiKey(_) => AuthMode::ApiKey, Self::Chatgpt(_) => AuthMode::Chatgpt, Self::ChatgptAuthTokens(_) => AuthMode::ChatgptAuthTokens, + Self::Headers(_) => AuthMode::Headers, Self::AgentIdentity(_) => AuthMode::AgentIdentity, Self::PersonalAccessToken(_) => AuthMode::PersonalAccessToken, Self::BedrockApiKey(_) => AuthMode::BedrockApiKey, @@ -497,7 +461,10 @@ impl CodexAuth { } fn supports_unauthorized_recovery(&self) -> bool { - matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_)) + matches!( + self, + Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::Headers(_) + ) } /// Returns `None` if `auth_mode() != AuthMode::ApiKey`. @@ -506,6 +473,7 @@ impl CodexAuth { Self::ApiKey(auth) => Some(auth.api_key.as_str()), Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) + | Self::Headers(_) | Self::AgentIdentity(_) | Self::PersonalAccessToken(_) | Self::BedrockApiKey(_) => None, @@ -536,6 +504,9 @@ impl CodexAuth { Self::AgentIdentity(_) => Err(std::io::Error::other( "agent identity auth does not expose a bearer token", )), + Self::Headers(_) => Err(std::io::Error::other( + "header auth does not expose a bearer token", + )), Self::PersonalAccessToken(auth) => Ok(auth.access_token().to_string()), Self::BedrockApiKey(_) => Err(std::io::Error::other( "Bedrock API key auth does not expose a Codex bearer token", @@ -546,6 +517,7 @@ impl CodexAuth { /// Returns `None` if Codex backend auth does not expose an account id. pub fn get_account_id(&self) -> Option { match self { + Self::Headers(_) => None, Self::AgentIdentity(auth) => Some(auth.account_id().to_string()), Self::PersonalAccessToken(auth) => Some(auth.account_id().to_string()), _ => self.get_current_token_data().and_then(|t| t.account_id), @@ -555,6 +527,7 @@ impl CodexAuth { /// Returns false if Codex backend auth omits the FedRAMP claim. pub fn is_fedramp_account(&self) -> bool { match self { + Self::Headers(_) => false, Self::AgentIdentity(auth) => auth.is_fedramp_account(), Self::PersonalAccessToken(auth) => auth.is_fedramp_account(), _ => self @@ -566,6 +539,7 @@ impl CodexAuth { /// Returns `None` if Codex backend auth does not expose an account email. pub fn get_account_email(&self) -> Option { match self { + Self::Headers(_) => None, Self::AgentIdentity(auth) => auth.email().map(str::to_string), Self::PersonalAccessToken(auth) => auth.email().map(str::to_string), _ => self.get_current_token_data().and_then(|t| t.id_token.email), @@ -575,6 +549,7 @@ impl CodexAuth { /// Returns `None` if Codex backend auth does not expose a ChatGPT user id. pub fn get_chatgpt_user_id(&self) -> Option { match self { + Self::Headers(_) => None, Self::AgentIdentity(auth) => Some(auth.chatgpt_user_id().to_string()), Self::PersonalAccessToken(auth) => Some(auth.chatgpt_user_id().to_string()), _ => self @@ -587,6 +562,9 @@ impl CodexAuth { /// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…) /// for UI or product decisions based on the user's subscription. pub fn account_plan_type(&self) -> Option { + if matches!(self, Self::Headers(_)) { + return None; + } if let Self::AgentIdentity(auth) = self { return Some(auth.plan_type()); } @@ -613,6 +591,7 @@ impl CodexAuth { Self::Chatgpt(auth) => &auth.state, Self::ChatgptAuthTokens(auth) => &auth.state, Self::ApiKey(_) + | Self::Headers(_) | Self::AgentIdentity(_) | Self::PersonalAccessToken(_) | Self::BedrockApiKey(_) => return None, @@ -658,6 +637,7 @@ impl CodexAuth { Self::AgentIdentity(auth) => Ok(Some(auth.clone())), Self::ApiKey(_) | Self::ChatgptAuthTokens(_) + | Self::Headers(_) | Self::PersonalAccessToken(_) | Self::BedrockApiKey(_) => Ok(None), Self::Chatgpt(_) => { @@ -747,6 +727,24 @@ impl CodexAuth { Self::Chatgpt(ChatgptAuth { state, storage }) } + /// Constructs in-memory ChatGPT auth from externally managed tokens. + pub fn from_external_chatgpt_tokens( + access_token: &str, + chatgpt_account_id: &str, + chatgpt_plan_type: Option<&str>, + ) -> std::io::Result { + let auth_dot_json = AuthDotJson::from_external_access_token( + access_token, + chatgpt_account_id, + chatgpt_plan_type, + )?; + let state = ChatgptAuthState { + auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), + client: create_client(), + }; + Ok(Self::ChatgptAuthTokens(ChatgptAuthTokens { state })) + } + pub fn from_api_key(api_key: &str) -> Self { Self::ApiKey(ApiKeyAuth { api_key: api_key.to_owned(), @@ -805,7 +803,7 @@ impl ChatgptAuth { &self.storage } - fn client(&self) -> &CodexHttpClient { + fn client(&self) -> &HttpClient { &self.state.client } @@ -1086,10 +1084,12 @@ async fn enforce_login_restrictions_with_agent_identity_authapi_base_url( | (ForcedLoginMethod::Api, AuthMode::BedrockApiKey) => None, (ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt) | (ForcedLoginMethod::Chatgpt, AuthMode::ChatgptAuthTokens) + | (ForcedLoginMethod::Chatgpt, AuthMode::Headers) | (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity) | (ForcedLoginMethod::Chatgpt, AuthMode::PersonalAccessToken) => None, (ForcedLoginMethod::Api, AuthMode::Chatgpt) | (ForcedLoginMethod::Api, AuthMode::ChatgptAuthTokens) + | (ForcedLoginMethod::Api, AuthMode::Headers) | (ForcedLoginMethod::Api, AuthMode::AgentIdentity) | (ForcedLoginMethod::Api, AuthMode::PersonalAccessToken) => Some( "API key login is required, but ChatGPT is currently being used. Logging out." @@ -1114,7 +1114,9 @@ async fn enforce_login_restrictions_with_agent_identity_authapi_base_url( if let Some(expected_account_ids) = config.forced_chatgpt_workspace_id.as_deref() { let chatgpt_account_id = match &auth { - CodexAuth::ApiKey(_) | CodexAuth::BedrockApiKey(_) => return Ok(()), + CodexAuth::ApiKey(_) | CodexAuth::Headers(_) | CodexAuth::BedrockApiKey(_) => { + return Ok(()); + } CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => { auth.get_account_id() } @@ -1331,7 +1333,7 @@ fn persist_tokens( // The caller is responsible for persisting any returned tokens. async fn request_chatgpt_token_refresh( refresh_token: String, - client: &CodexHttpClient, + client: &HttpClient, ) -> Result { let refresh_request = RefreshRequest { client_id: oauth_client_id(), @@ -1456,26 +1458,23 @@ fn refresh_token_endpoint() -> String { } impl AuthDotJson { - fn from_external_tokens(external: &ExternalAuthTokens) -> std::io::Result { - let Some(chatgpt_metadata) = external.chatgpt_metadata() else { - return Err(std::io::Error::other( - "external auth tokens are missing ChatGPT metadata", - )); - }; + fn from_external_access_token( + access_token: &str, + chatgpt_account_id: &str, + chatgpt_plan_type: Option<&str>, + ) -> std::io::Result { let mut token_info = - parse_chatgpt_jwt_claims(&external.access_token).map_err(std::io::Error::other)?; - token_info.chatgpt_account_id = Some(chatgpt_metadata.account_id.clone()); - token_info.chatgpt_plan_type = chatgpt_metadata - .plan_type - .as_deref() + parse_chatgpt_jwt_claims(access_token).map_err(std::io::Error::other)?; + token_info.chatgpt_account_id = Some(chatgpt_account_id.to_string()); + token_info.chatgpt_plan_type = chatgpt_plan_type .map(InternalPlanType::from_raw_value) .or(token_info.chatgpt_plan_type) .or(Some(InternalPlanType::Unknown("unknown".to_string()))); let tokens = TokenData { id_token: token_info, - access_token: external.access_token.clone(), + access_token: access_token.to_string(), refresh_token: String::new(), - account_id: Some(chatgpt_metadata.account_id.clone()), + account_id: Some(chatgpt_account_id.to_string()), }; Ok(Self { @@ -1489,19 +1488,6 @@ impl AuthDotJson { }) } - fn from_external_access_token( - access_token: &str, - chatgpt_account_id: &str, - chatgpt_plan_type: Option<&str>, - ) -> std::io::Result { - let external = ExternalAuthTokens::chatgpt( - access_token, - chatgpt_account_id, - chatgpt_plan_type.map(str::to_string), - ); - Self::from_external_tokens(&external) - } - pub(super) fn resolved_mode(&self) -> AuthMode { if let Some(mode) = self.auth_mode { return mode; @@ -1595,14 +1581,9 @@ enum UnauthorizedRecoveryMode { // 2. Attempt to refresh the token using OAuth token refresh flow. // If after both steps the server still responds with 401 we let the error bubble to the user. // -// For external auth sources, UnauthorizedRecovery retries once. -// -// - External ChatGPT auth tokens (`chatgptAuthTokens`) are refreshed by asking -// the parent app for new tokens through the configured -// `ExternalAuth`, persisting them in the ephemeral auth store, and -// reloading the cached auth snapshot. -// - External bearer auth sources for custom model providers rerun the provider -// auth command without touching disk. +// For external auth sources, UnauthorizedRecovery retries once by asking the +// configured provider to refresh and caching the returned auth through the same +// path used by other auth sources. pub struct UnauthorizedRecovery { manager: Arc, step: UnauthorizedRecoveryStep, @@ -1625,11 +1606,7 @@ impl UnauthorizedRecovery { fn new(manager: Arc) -> Self { let cached_auth = manager.auth_cached(); let expected_account_id = cached_auth.as_ref().and_then(CodexAuth::get_account_id); - let mode = if manager.has_external_api_key_auth() - || cached_auth - .as_ref() - .is_some_and(CodexAuth::is_external_chatgpt_tokens) - { + let mode = if manager.has_external_auth() { UnauthorizedRecoveryMode::External } else { UnauthorizedRecoveryMode::Managed @@ -1765,9 +1742,7 @@ impl UnauthorizedRecovery { }); } UnauthorizedRecoveryStep::ExternalRefresh => { - self.manager - .refresh_external_auth(ExternalAuthRefreshReason::Unauthorized) - .await?; + self.manager.refresh_token_from_authority().await?; self.step = UnauthorizedRecoveryStep::Done; return Ok(UnauthorizedRecoveryStepResult { auth_state_changed: Some(true), @@ -2020,7 +1995,10 @@ impl AuthManager { /// Current cached auth (clone) without attempting a refresh. pub fn auth_cached(&self) -> Option { - self.inner.read().ok().and_then(|c| c.auth.clone()) + self.inner + .read() + .ok() + .and_then(|cached| cached.auth.clone()) } /// Subscribes to cached auth changes that can affect request recovery. @@ -2043,8 +2021,9 @@ impl AuthManager { /// a guarded reload and then refreshes only if the on-disk auth is unchanged. #[instrument(level = "trace", skip_all)] pub async fn auth(&self) -> Option { - if let Some(auth) = self.resolve_external_api_key_auth().await { - return Some(auth); + if self.has_external_auth() { + self.reload().await; + return self.auth_cached(); } let auth = self.auth_cached()?; @@ -2120,11 +2099,10 @@ impl AuthManager { .await } - /// Force a reload of the auth information from auth.json. Returns - /// whether the auth value changed. + /// Reloads auth from the active source. Returns whether the auth value changed. pub async fn reload(&self) -> bool { tracing::info!("Reloading auth"); - let new_auth = self.load_auth_from_storage().await; + let new_auth = self.load_auth().await; self.set_cached_auth(new_auth) } @@ -2140,7 +2118,7 @@ impl AuthManager { } }; - let new_auth = self.load_auth_from_storage().await; + let new_auth = self.load_auth().await; let new_account_id = new_auth.as_ref().and_then(CodexAuth::get_account_id); if new_account_id.as_deref() != Some(expected_account_id) { @@ -2172,6 +2150,7 @@ impl AuthManager { | (AuthMode::ChatgptAuthTokens, AuthMode::ChatgptAuthTokens) => { a.get_current_auth_json() == b.get_current_auth_json() } + (AuthMode::Headers, AuthMode::Headers) => a == b, (AuthMode::AgentIdentity, AuthMode::AgentIdentity) => match (a, b) { (CodexAuth::AgentIdentity(a), CodexAuth::AgentIdentity(b)) => { a.record() == b.record() @@ -2213,7 +2192,17 @@ impl AuthManager { } } - async fn load_auth_from_storage(&self) -> Option { + async fn load_auth(&self) -> Option { + if let Some(external_auth) = self.external_auth() { + return match self.resolve_external_auth(&external_auth).await { + Ok(auth) => Some(auth), + Err(err) => { + tracing::error!("Failed to resolve external auth: {err}"); + None + } + }; + } + let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id(); load_auth( &self.codex_home, @@ -2250,15 +2239,22 @@ impl AuthManager { } } - pub fn set_external_auth(&self, external_auth: Arc) { - if let Ok(mut guard) = self.external_auth.write() { - *guard = Some(external_auth); - } + pub async fn set_external_auth( + &self, + external_auth: Arc, + ) -> Result<(), RefreshTokenError> { + let auth = self.resolve_external_auth(&external_auth).await?; + *self.external_auth.write().map_err(|_| { + RefreshTokenError::Transient(std::io::Error::other("external auth lock is poisoned")) + })? = Some(external_auth); + self.commit_external_auth(auth) } pub fn clear_external_auth(&self) { - if let Ok(mut guard) = self.external_auth.write() { - *guard = None; + if let Ok(mut external_auth) = self.external_auth.write() + && external_auth.take().is_some() + { + self.set_cached_auth(/*new_auth*/ None); } } @@ -2340,41 +2336,33 @@ impl AuthManager { self.external_auth .read() .ok() - .and_then(|guard| guard.as_ref().cloned()) - } - - fn external_auth_mode(&self) -> Option { - self.external_auth() - .as_ref() - .map(|external_auth| external_auth.auth_mode()) + .and_then(|external_auth| external_auth.as_ref().map(Arc::clone)) } fn has_external_api_key_auth(&self) -> bool { - self.external_auth_mode() == Some(AuthMode::ApiKey) + self.has_external_auth() + && self + .auth_cached() + .as_ref() + .is_some_and(CodexAuth::is_api_key_auth) } - async fn resolve_external_api_key_auth(&self) -> Option { - if !self.has_external_api_key_auth() { - return None; - } - - let external_auth = self.external_auth()?; - - match external_auth.resolve().await { - Ok(Some(tokens)) => Some(CodexAuth::from_api_key(&tokens.access_token)), - Ok(None) => None, - Err(err) => { - tracing::error!("Failed to resolve external API key auth: {err}"); - None - } - } + async fn resolve_external_auth( + &self, + external_auth: &Arc, + ) -> Result { + let auth = external_auth + .resolve() + .await + .map_err(RefreshTokenError::Transient)?; + self.validate_external_auth(&auth)?; + Ok(auth) } - /// Attempt to refresh the token by first performing a guarded reload. Auth - /// is reloaded from storage only when the account id matches the currently - /// cached account id. If the persisted token differs from the cached token, we - /// can assume that some other instance already refreshed it. If the persisted - /// token is the same as the cached, then ask the token authority to refresh. + /// Attempt to refresh the token by first performing a guarded reload from + /// the active auth source. If the loaded token differs from the cached token, + /// we can assume that the source already refreshed it. Otherwise, ask the + /// token authority to refresh. pub async fn refresh_token(&self) -> Result<(), RefreshTokenError> { let _refresh_guard = self.refresh_lock.acquire().await.map_err(|_| { RefreshTokenError::Permanent(RefreshTokenFailedError::new( @@ -2412,9 +2400,8 @@ impl AuthManager { } /// Attempt to refresh the current auth token from the authority that issued - /// the token. On success, reloads the auth state from disk so other components - /// observe refreshed token. If the token refresh fails, returns the error to - /// the caller. + /// it and update the shared cache. If the token refresh fails, returns the + /// error to the caller. pub async fn refresh_token_from_authority(&self) -> Result<(), RefreshTokenError> { let _refresh_guard = self.refresh_lock.acquire().await.map_err(|_| { RefreshTokenError::Permanent(RefreshTokenFailedError::new( @@ -2437,24 +2424,27 @@ impl AuthManager { } let attempted_auth = auth.clone(); - let result = match auth { - CodexAuth::ChatgptAuthTokens(_) => { - self.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized) - .await + let result = if self.has_external_auth() { + self.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized) + .await + } else { + match auth { + CodexAuth::Chatgpt(chatgpt_auth) => { + let token_data = chatgpt_auth.current_token_data().ok_or_else(|| { + RefreshTokenError::Transient(std::io::Error::other( + "Token data is not available.", + )) + })?; + self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token) + .await + } + CodexAuth::ApiKey(_) + | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::Headers(_) + | CodexAuth::AgentIdentity(_) + | CodexAuth::PersonalAccessToken(_) + | CodexAuth::BedrockApiKey(_) => Ok(()), } - CodexAuth::Chatgpt(chatgpt_auth) => { - let token_data = chatgpt_auth.current_token_data().ok_or_else(|| { - RefreshTokenError::Transient(std::io::Error::other( - "Token data is not available.", - )) - })?; - self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token) - .await - } - CodexAuth::ApiKey(_) - | CodexAuth::AgentIdentity(_) - | CodexAuth::PersonalAccessToken(_) - | CodexAuth::BedrockApiKey(_) => Ok(()), }; if let Err(RefreshTokenError::Permanent(error)) = &result { self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error); @@ -2473,6 +2463,7 @@ impl AuthManager { self.keyring_backend_kind, )?; // Always reload to clear any cached auth (even if file absent). + self.clear_external_auth(); self.reload().await; Ok(removed) } @@ -2492,23 +2483,18 @@ impl AuthManager { self.keyring_backend_kind, )?; // Always reload to clear any cached auth (even if file absent). + self.clear_external_auth(); self.reload().await; Ok(result) } /// Returns the precise kind of credentials backing the current authentication. pub fn get_api_auth_mode(&self) -> Option { - if self.has_external_api_key_auth() { - return Some(AuthMode::ApiKey); - } self.auth_cached().as_ref().map(CodexAuth::api_auth_mode) } /// Returns the effective backend auth mode for the current authentication. pub fn auth_mode(&self) -> Option { - if self.has_external_api_key_auth() { - return Some(AuthMode::ApiKey); - } self.auth_cached().as_ref().map(CodexAuth::auth_mode) } @@ -2550,7 +2536,6 @@ impl AuthManager { "external auth is not configured", ))); }; - let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id(); let previous_account_id = self .auth_cached() .as_ref() @@ -2564,34 +2549,44 @@ impl AuthManager { .refresh(context) .await .map_err(RefreshTokenError::Transient)?; - if external_auth.auth_mode() == AuthMode::ApiKey { - return Ok(()); + self.validate_external_auth(&refreshed)?; + self.commit_external_auth(refreshed)?; + Ok(()) + } + + fn commit_external_auth(&self, auth: CodexAuth) -> Result<(), RefreshTokenError> { + if auth.is_external_chatgpt_tokens() { + let auth_dot_json = auth.get_current_auth_json().ok_or_else(|| { + RefreshTokenError::Transient(std::io::Error::other( + "external ChatGPT auth tokens are missing auth state", + )) + })?; + // App/connectors paths still construct independent AuthManagers from Config. Mirror + // external ChatGPT auth into the process-local store so those managers see it too. + save_auth( + &self.codex_home, + &auth_dot_json, + AuthCredentialsStoreMode::Ephemeral, + AuthKeyringBackendKind::default(), + ) + .map_err(RefreshTokenError::Transient)?; } - let Some(chatgpt_metadata) = refreshed.chatgpt_metadata() else { - return Err(RefreshTokenError::Transient(std::io::Error::other( - "external auth refresh did not return ChatGPT metadata", - ))); - }; - if let Some(expected_workspace_ids) = forced_chatgpt_workspace_id.as_deref() - && !expected_workspace_ids.contains(&chatgpt_metadata.account_id) + + self.set_cached_auth(Some(auth)); + Ok(()) + } + + fn validate_external_auth(&self, auth: &CodexAuth) -> Result<(), RefreshTokenError> { + if let Some(account_id) = auth.get_account_id() + && let Some(expected_workspace_ids) = self.forced_chatgpt_workspace_id() + && !expected_workspace_ids.contains(&account_id) { return Err(RefreshTokenError::Transient(std::io::Error::other( format!( - "external auth refresh returned workspace {:?}, expected one of {:?}", - chatgpt_metadata.account_id, expected_workspace_ids, + "external auth returned workspace {account_id:?}, expected one of {expected_workspace_ids:?}" ), ))); } - let auth_dot_json = - AuthDotJson::from_external_tokens(&refreshed).map_err(RefreshTokenError::Transient)?; - save_auth( - &self.codex_home, - &auth_dot_json, - AuthCredentialsStoreMode::Ephemeral, - AuthKeyringBackendKind::default(), - ) - .map_err(RefreshTokenError::Transient)?; - self.reload().await; Ok(()) } diff --git a/codex-rs/login/src/auth/mod.rs b/codex-rs/login/src/auth/mod.rs index d87c7cc6ad..16477d7827 100644 --- a/codex-rs/login/src/auth/mod.rs +++ b/codex-rs/login/src/auth/mod.rs @@ -1,5 +1,6 @@ mod access_token; mod agent_identity; +mod auth_headers; mod bedrock_api_key; pub mod default_client; pub mod error; @@ -11,6 +12,7 @@ mod external_bearer; mod manager; mod revoke; +pub use auth_headers::AuthHeaders; pub use bedrock_api_key::BedrockApiKeyAuth; pub use bedrock_api_key::login_with_bedrock_api_key; pub use error::RefreshTokenFailedError; diff --git a/codex-rs/login/src/auth/personal_access_token.rs b/codex-rs/login/src/auth/personal_access_token.rs index ea75648d1b..09c6d5d2b3 100644 --- a/codex-rs/login/src/auth/personal_access_token.rs +++ b/codex-rs/login/src/auth/personal_access_token.rs @@ -1,4 +1,4 @@ -use codex_client::CodexHttpClient; +use codex_http_client::HttpClient; use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::auth::PlanType as InternalPlanType; use serde::Deserialize; @@ -77,7 +77,7 @@ impl PersonalAccessTokenAuth { } async fn hydrate_personal_access_token( - client: &CodexHttpClient, + client: &HttpClient, endpoint: &str, access_token: &str, ) -> std::io::Result { diff --git a/codex-rs/login/src/auth/revoke.rs b/codex-rs/login/src/auth/revoke.rs index 076b8b53da..ca22adb571 100644 --- a/codex-rs/login/src/auth/revoke.rs +++ b/codex-rs/login/src/auth/revoke.rs @@ -7,7 +7,7 @@ use serde::Serialize; use std::time::Duration; -use codex_client::CodexHttpClient; +use codex_http_client::HttpClient; use codex_protocol::auth::AuthMode; use super::manager::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; @@ -95,7 +95,7 @@ fn resolved_auth_mode(auth_dot_json: &AuthDotJson) -> AuthMode { } async fn revoke_oauth_token( - client: &CodexHttpClient, + client: &HttpClient, endpoint: &str, token: &str, kind: RevokeTokenKind, @@ -181,7 +181,7 @@ mod tests { .mount(&server) .await; - let client = CodexHttpClient::new(reqwest::Client::new()); + let client = HttpClient::new(reqwest::Client::new()); let endpoint = format!("{}/oauth/revoke", server.uri()); let error = revoke_oauth_token( &client, diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 44c2f1c4d3..9b3f82f382 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -6,9 +6,10 @@ mod device_code_auth; mod outbound_proxy; mod pkce; mod server; +mod success_page; -pub use codex_client::BuildCustomCaTransportError as BuildLoginHttpClientError; pub use codex_config::types::AuthCredentialsStoreMode; +pub use codex_http_client::BuildCustomCaTransportError as BuildLoginHttpClientError; pub use device_code_auth::DeviceCode; pub use device_code_auth::complete_device_code_login; pub use device_code_auth::request_device_code; @@ -17,10 +18,14 @@ pub use server::LoginServer; pub use server::ServerOptions; pub use server::ShutdownHandle; pub use server::run_login_server; +pub use success_page::CODEX_OPEN_APP_URL; +pub use success_page::LoginSuccessPage; +pub use success_page::LoginSuccessPageBrand; pub use auth::AgentIdentityAuthPolicy; pub use auth::AuthConfig; pub use auth::AuthDotJson; +pub use auth::AuthHeaders; pub use auth::AuthKeyringBackendKind; pub use auth::AuthManager; pub use auth::AuthManagerConfig; @@ -30,11 +35,9 @@ pub use auth::CODEX_ACCESS_TOKEN_ENV_VAR; pub use auth::CODEX_API_KEY_ENV_VAR; pub use auth::CodexAuth; pub use auth::ExternalAuth; -pub use auth::ExternalAuthChatgptMetadata; pub use auth::ExternalAuthFuture; pub use auth::ExternalAuthRefreshContext; pub use auth::ExternalAuthRefreshReason; -pub use auth::ExternalAuthTokens; pub use auth::OPENAI_API_KEY_ENV_VAR; pub use auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; pub use auth::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; diff --git a/codex-rs/login/src/outbound_proxy.rs b/codex-rs/login/src/outbound_proxy.rs index bf7dfbeb4d..79fcf76de1 100644 --- a/codex-rs/login/src/outbound_proxy.rs +++ b/codex-rs/login/src/outbound_proxy.rs @@ -1,4 +1,5 @@ -use codex_client::OutboundProxyConfig; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; /// Auth-layer adapter around client-owned proxy policy. /// @@ -6,17 +7,17 @@ use codex_client::OutboundProxyConfig; /// client layer. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthRouteConfig { - route_config: OutboundProxyConfig, + http_client_factory: HttpClientFactory, } impl AuthRouteConfig { pub fn respect_system_proxy() -> Self { Self { - route_config: OutboundProxyConfig::respect_system_proxy(), + http_client_factory: HttpClientFactory::new(OutboundProxyPolicy::RespectSystemProxy), } } - pub(crate) fn route_config(&self) -> &OutboundProxyConfig { - &self.route_config + pub(crate) fn http_client_factory(&self) -> &HttpClientFactory { + &self.http_client_factory } } diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 607e47fefe..01671bf0b1 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -32,6 +32,10 @@ use crate::default_client::originator; use crate::outbound_proxy::AuthRouteConfig; use crate::pkce::PkceCodes; use crate::pkce::generate_pkce; +use crate::success_page::LoginSuccessPage; +use crate::success_page::LoginSuccessRedirect; +use crate::success_page::compose_success_url; +use crate::success_page::jwt_auth_claims; use crate::token_data::TokenData; use crate::token_data::parse_chatgpt_jwt_claims; use base64::Engine; @@ -50,7 +54,7 @@ use tracing::error; use tracing::info; use tracing::warn; -const DEFAULT_ISSUER: &str = "https://auth.openai.com"; +pub(super) const DEFAULT_ISSUER: &str = "https://auth.openai.com"; const DEFAULT_PORT: u16 = 1455; // Keep in sync with the Codex CLI Hydra redirect URI allow-list. const FALLBACK_PORT: u16 = 1457; @@ -70,6 +74,7 @@ pub struct ServerOptions { pub force_state: Option, pub forced_chatgpt_workspace_id: Option>, pub codex_streamlined_login: bool, + pub login_success_page: LoginSuccessPage, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, pub auth_keyring_backend_kind: AuthKeyringBackendKind, pub auth_route_config: Option, @@ -94,6 +99,7 @@ impl ServerOptions { force_state: None, forced_chatgpt_workspace_id, codex_streamlined_login: false, + login_success_page: LoginSuccessPage::default(), cli_auth_credentials_store_mode, auth_keyring_backend_kind, auth_route_config, @@ -207,28 +213,62 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { let url_raw = req.url().to_string(); let response = - process_request(&url_raw, &opts, &redirect_uri, &pkce, actual_port, &state).await; + process_request( + &url_raw, + &opts, + &redirect_uri, + &pkce, + actual_port, + &state, + ) + .await; let exit_result = match response { HandledRequest::Response(response) => { let _ = tokio::task::spawn_blocking(move || req.respond(response)).await; None } + HandledRequest::RedirectWithHeader(header) => { + let redirect = Response::empty(302).with_header(header); + let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await; + None + } HandledRequest::ResponseAndExit { headers, body, result, } => { let _ = tokio::task::spawn_blocking(move || { - send_response_with_disconnect(req, headers, body) + send_response_with_disconnect( + req, + StatusCode(200), + headers, + body, + ) }) .await; Some(result) } - HandledRequest::RedirectWithHeader(header) => { - let redirect = Response::empty(302).with_header(header); - let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await; - None + HandledRequest::RedirectAndExit(header) => { + match tokio::task::spawn_blocking(move || { + send_response_with_disconnect( + req, + StatusCode(302), + vec![header], + Vec::new(), + ) + }) + .await + { + Ok(Ok(())) => {} + Ok(Err(err)) => { + warn!("failed to send hosted login redirect: {err}"); + } + Err(err) => { + warn!("hosted login redirect task failed: {err}"); + } + } + Some(Ok(())) } }; @@ -258,6 +298,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { enum HandledRequest { Response(Response>>), RedirectWithHeader(Header), + RedirectAndExit(Header), ResponseAndExit { headers: Vec
, body: Vec, @@ -392,15 +433,26 @@ async fn process_request( ); } - let success_url = compose_success_url( + let redirect = compose_success_url( actual_port, &opts.issuer, &tokens.id_token, &tokens.access_token, opts.codex_streamlined_login, + &opts.login_success_page, ); - match tiny_http::Header::from_bytes(&b"Location"[..], success_url.as_bytes()) { - Ok(header) => HandledRequest::RedirectWithHeader(header), + let url = match &redirect { + LoginSuccessRedirect::Local(url) | LoginSuccessRedirect::Hosted(url) => url, + }; + match tiny_http::Header::from_bytes(&b"Location"[..], url.as_bytes()) { + Ok(header) => match redirect { + LoginSuccessRedirect::Local(_) => { + HandledRequest::RedirectWithHeader(header) + } + LoginSuccessRedirect::Hosted(_) => { + HandledRequest::RedirectAndExit(header) + } + }, Err(_) => login_error_response( "Sign-in completed but redirecting back to Codex failed.", io::ErrorKind::Other, @@ -465,10 +517,10 @@ async fn process_request( /// server-side connection persistence, but it does not. fn send_response_with_disconnect( req: Request, + status: StatusCode, mut headers: Vec
, body: Vec, ) -> io::Result<()> { - let status = StatusCode(200); let mut writer = req.into_writer(); let reason = status.default_reason_phrase(); write!(writer, "HTTP/1.1 {} {}\r\n", status.0, reason)?; @@ -850,94 +902,6 @@ pub(crate) async fn persist_tokens_async( .map_err(|e| io::Error::other(format!("persist task failed: {e}")))? } -fn compose_success_url( - port: u16, - issuer: &str, - id_token: &str, - access_token: &str, - codex_streamlined_login: bool, -) -> String { - let token_claims = jwt_auth_claims(id_token); - let access_claims = jwt_auth_claims(access_token); - - let org_id = token_claims - .get("organization_id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let project_id = token_claims - .get("project_id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let completed_onboarding = token_claims - .get("completed_platform_onboarding") - .and_then(JsonValue::as_bool) - .unwrap_or(false); - let is_org_owner = token_claims - .get("is_org_owner") - .and_then(JsonValue::as_bool) - .unwrap_or(false); - let needs_setup = (!completed_onboarding) && is_org_owner; - let plan_type = access_claims - .get("chatgpt_plan_type") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - let platform_url = if issuer == DEFAULT_ISSUER { - "https://platform.openai.com" - } else { - "https://platform.api.openai.org" - }; - - let mut params = vec![ - ("id_token", id_token.to_string()), - ("needs_setup", needs_setup.to_string()), - ("org_id", org_id.to_string()), - ("project_id", project_id.to_string()), - ("plan_type", plan_type.to_string()), - ("platform_url", platform_url.to_string()), - ]; - if codex_streamlined_login { - params.push(("codex_streamlined_login", "true".to_string())); - } - let qs = params - .drain(..) - .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v))) - .collect::>() - .join("&"); - format!("http://localhost:{port}/success?{qs}") -} - -fn jwt_auth_claims(jwt: &str) -> serde_json::Map { - let mut parts = jwt.split('.'); - let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) { - (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), - _ => { - eprintln!("Invalid JWT format while extracting claims"); - return serde_json::Map::new(); - } - }; - match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64) { - Ok(bytes) => match serde_json::from_slice::(&bytes) { - Ok(mut v) => { - if let Some(obj) = v - .get_mut("https://api.openai.com/auth") - .and_then(|x| x.as_object_mut()) - { - return obj.clone(); - } - eprintln!("JWT payload missing expected 'https://api.openai.com/auth' object"); - } - Err(e) => { - eprintln!("Failed to parse JWT JSON payload: {e}"); - } - }, - Err(e) => { - eprintln!("Failed to base64url-decode JWT payload: {e}"); - } - } - serde_json::Map::new() -} - /// Validates the ID token against an optional workspace restriction. pub(crate) fn ensure_workspace_allowed( expected: Option<&[String]>, @@ -1184,9 +1148,7 @@ pub(crate) async fn obtain_api_key( mod tests { use pretty_assertions::assert_eq; - use super::DEFAULT_ISSUER; use super::TokenEndpointErrorDetail; - use super::compose_success_url; use super::html_escape; use super::is_missing_codex_entitlement_error; use super::parse_token_endpoint_error; @@ -1293,43 +1255,6 @@ mod tests { ); } - #[test] - fn compose_success_url_omits_streamlined_success_by_default() { - let url = url::Url::parse(&compose_success_url( - /*port*/ 1455, - DEFAULT_ISSUER, - "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", - "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", - /*codex_streamlined_login*/ false, - )) - .expect("success url should parse"); - - assert_eq!( - url.query_pairs() - .find(|(key, _)| key == "codex_streamlined_login"), - None - ); - } - - #[test] - fn compose_success_url_includes_streamlined_success_when_requested() { - let url = url::Url::parse(&compose_success_url( - /*port*/ 1455, - DEFAULT_ISSUER, - "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", - "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", - /*codex_streamlined_login*/ true, - )) - .expect("success url should parse"); - - assert_eq!( - url.query_pairs() - .find(|(key, _)| key == "codex_streamlined_login") - .map(|(_, value)| value.into_owned()), - Some("true".to_string()) - ); - } - #[test] fn render_login_error_page_escapes_dynamic_fields() { let body = String::from_utf8(render_login_error_page( diff --git a/codex-rs/login/src/success_page.rs b/codex-rs/login/src/success_page.rs new file mode 100644 index 0000000000..4c7983e63c --- /dev/null +++ b/codex-rs/login/src/success_page.rs @@ -0,0 +1,143 @@ +use base64::Engine; +use serde_json::Value as JsonValue; +use url::Url; + +use crate::server::DEFAULT_ISSUER; + +pub const CODEX_OPEN_APP_URL: &str = "https://chatgpt.com/codex/open-app"; + +#[derive(Debug, Clone, Default, Eq, PartialEq)] +pub enum LoginSuccessPage { + #[default] + Local, + Hosted { + url: Url, + app_brand: LoginSuccessPageBrand, + }, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum LoginSuccessPageBrand { + Codex, + Chatgpt, +} + +impl LoginSuccessPageBrand { + fn as_str(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::Chatgpt => "chatgpt", + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum LoginSuccessRedirect { + Local(String), + Hosted(String), +} + +pub(crate) fn compose_success_url( + port: u16, + issuer: &str, + id_token: &str, + access_token: &str, + codex_streamlined_login: bool, + login_success_page: &LoginSuccessPage, +) -> LoginSuccessRedirect { + let token_claims = jwt_auth_claims(id_token); + + let org_id = token_claims + .get("organization_id") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let project_id = token_claims + .get("project_id") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let completed_onboarding = token_claims + .get("completed_platform_onboarding") + .and_then(JsonValue::as_bool) + .unwrap_or(false); + let is_org_owner = token_claims + .get("is_org_owner") + .and_then(JsonValue::as_bool) + .unwrap_or(false); + let needs_setup = !completed_onboarding && is_org_owner; + if !needs_setup && let LoginSuccessPage::Hosted { url, app_brand } = login_success_page { + let mut success_url = url.clone(); + success_url.set_query(None); + success_url + .query_pairs_mut() + .append_pair("source", "login") + .append_pair("app_brand", app_brand.as_str()); + return LoginSuccessRedirect::Hosted(success_url.into()); + } + + let access_claims = jwt_auth_claims(access_token); + let plan_type = access_claims + .get("chatgpt_plan_type") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let platform_url = if issuer == DEFAULT_ISSUER { + "https://platform.openai.com" + } else { + "https://platform.api.openai.org" + }; + let mut params = vec![ + ("id_token", id_token.to_string()), + ("needs_setup", needs_setup.to_string()), + ("org_id", org_id.to_string()), + ("project_id", project_id.to_string()), + ("plan_type", plan_type.to_string()), + ("platform_url", platform_url.to_string()), + ]; + if codex_streamlined_login { + params.push(("codex_streamlined_login", "true".to_string())); + } + let query = params + .into_iter() + .map(|(key, value)| format!("{key}={}", urlencoding::encode(&value))) + .collect::>() + .join("&"); + LoginSuccessRedirect::Local(format!("http://localhost:{port}/success?{query}")) +} + +pub(crate) fn jwt_auth_claims(jwt: &str) -> serde_json::Map { + let mut parts = jwt.split('.'); + let (_header, payload, _signature) = match (parts.next(), parts.next(), parts.next()) { + (Some(header), Some(payload), Some(signature)) + if !header.is_empty() && !payload.is_empty() && !signature.is_empty() => + { + (header, payload, signature) + } + _ => { + eprintln!("Invalid JWT format while extracting claims"); + return serde_json::Map::new(); + } + }; + match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(mut value) => { + if let Some(claims) = value + .get_mut("https://api.openai.com/auth") + .and_then(JsonValue::as_object_mut) + { + return claims.clone(); + } + eprintln!("JWT payload missing expected 'https://api.openai.com/auth' object"); + } + Err(error) => { + eprintln!("Failed to parse JWT JSON payload: {error}"); + } + }, + Err(error) => { + eprintln!("Failed to base64url-decode JWT payload: {error}"); + } + } + serde_json::Map::new() +} + +#[cfg(test)] +#[path = "success_page_tests.rs"] +mod tests; diff --git a/codex-rs/login/src/success_page_tests.rs b/codex-rs/login/src/success_page_tests.rs new file mode 100644 index 0000000000..5ccfc9e563 --- /dev/null +++ b/codex-rs/login/src/success_page_tests.rs @@ -0,0 +1,120 @@ +use base64::Engine; +use pretty_assertions::assert_eq; +use serde_json::json; + +use super::*; + +#[test] +fn compose_success_url_uses_local_page_by_default() { + let LoginSuccessRedirect::Local(url) = compose_success_url( + /*port*/ 1455, + DEFAULT_ISSUER, + "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", + "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", + /*codex_streamlined_login*/ false, + &LoginSuccessPage::default(), + ) else { + panic!("expected local success redirect"); + }; + let url = Url::parse(&url).expect("success URL should parse"); + + assert_eq!(url.host_str(), Some("localhost")); + assert_eq!(url.path(), "/success"); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "codex_streamlined_login"), + None + ); +} + +#[test] +fn compose_success_url_uses_streamlined_local_page_when_requested() { + let LoginSuccessRedirect::Local(url) = compose_success_url( + /*port*/ 1455, + DEFAULT_ISSUER, + "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", + "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", + /*codex_streamlined_login*/ true, + &LoginSuccessPage::default(), + ) else { + panic!("expected local success redirect"); + }; + let url = Url::parse(&url).expect("success URL should parse"); + + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "codex_streamlined_login") + .map(|(_, value)| value.into_owned()), + Some("true".to_string()) + ); +} + +#[test] +fn compose_success_url_uses_hosted_page_when_requested() { + assert_eq!( + compose_success_url( + /*port*/ 1455, + DEFAULT_ISSUER, + "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", + "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", + /*codex_streamlined_login*/ false, + &LoginSuccessPage::Hosted { + url: Url::parse(CODEX_OPEN_APP_URL).expect("open app URL should parse"), + app_brand: LoginSuccessPageBrand::Chatgpt, + }, + ), + LoginSuccessRedirect::Hosted( + "https://chatgpt.com/codex/open-app?source=login&app_brand=chatgpt".to_string() + ) + ); +} + +#[test] +fn compose_success_url_keeps_setup_on_local_page() { + let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let payload = encode( + serde_json::to_string(&json!({ + "https://api.openai.com/auth": { + "completed_platform_onboarding": false, + "is_org_owner": true, + "organization_id": "org_123", + "project_id": "proj_123", + } + })) + .expect("payload should serialize") + .as_bytes(), + ); + let access_payload = encode( + serde_json::to_string(&json!({ + "https://api.openai.com/auth": { + "chatgpt_plan_type": "team", + } + })) + .expect("payload should serialize") + .as_bytes(), + ); + let id_token = format!("e30.{payload}.sig"); + let LoginSuccessRedirect::Local(url) = compose_success_url( + /*port*/ 1455, + DEFAULT_ISSUER, + &id_token, + &format!("e30.{access_payload}.sig"), + /*codex_streamlined_login*/ true, + &LoginSuccessPage::Hosted { + url: Url::parse(CODEX_OPEN_APP_URL).expect("open app URL should parse"), + app_brand: LoginSuccessPageBrand::Codex, + }, + ) else { + panic!("expected local success redirect"); + }; + let url = Url::parse(&url).expect("success URL should parse"); + + assert_eq!(url.host_str(), Some("localhost")); + assert_eq!(url.path(), "/success"); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "needs_setup") + .map(|(_, value)| value.into_owned()), + Some("true".to_string()) + ); +} diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index 6f7b4e4f83..dbcd396ef1 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -10,6 +10,8 @@ use anyhow::Result; use base64::Engine; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; +use codex_login::LoginSuccessPage; +use codex_login::LoginSuccessPageBrand; use codex_login::ServerOptions; use codex_login::run_login_server; use core_test_support::skip_if_no_network; @@ -131,6 +133,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { forced_chatgpt_workspace_id: Some(vec![chatgpt_account_id.to_string()]), codex_streamlined_login: false, auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + login_success_page: LoginSuccessPage::Local, }; let server = run_login_server(opts)?; assert!( @@ -141,13 +144,20 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { ); let login_port = server.actual_port; - // Simulate browser callback, and follow redirect to /success + // Simulate browser callback and assert the local success redirect before following it. let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::limited(5)) + .redirect(reqwest::redirect::Policy::none()) .build()?; let url = format!("http://127.0.0.1:{login_port}/auth/callback?code=abc&state=test_state_123"); let resp = client.get(&url).send().await?; - assert!(resp.status().is_success()); + assert_eq!(resp.status(), 302); + let success_url = resp.headers()["location"].to_str()?; + let success_url = Url::parse(success_url)?; + assert_eq!(success_url.host_str(), Some("localhost")); + assert_eq!(success_url.path(), "/success"); + + let success_resp = client.get(success_url).send().await?; + assert!(success_resp.status().is_success()); // Wait for server shutdown server.block_until_done().await?; @@ -169,6 +179,52 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { Ok(()) } +#[tokio::test] +async fn hosted_login_redirects_to_configured_open_app_url() -> Result<()> { + skip_if_no_network!(Ok(())); + + let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED); + let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); + let tmp = tempdir()?; + let server = run_login_server(ServerOptions { + codex_home: tmp.path().to_path_buf(), + cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, + client_id: codex_login::CLIENT_ID.to_string(), + issuer, + port: 0, + open_browser: false, + force_state: Some("streamlined_state".to_string()), + forced_chatgpt_workspace_id: None, + codex_streamlined_login: false, + login_success_page: LoginSuccessPage::Hosted { + url: Url::parse("http://localhost:3000/codex/open-app?source=old")?, + app_brand: LoginSuccessPageBrand::Chatgpt, + }, + auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + })?; + let login_port = server.actual_port; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + + let response = client + .get(format!( + "http://127.0.0.1:{login_port}/auth/callback?code=abc&state=streamlined_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" + ); + tokio::time::timeout(Duration::from_secs(1), server.block_until_done()).await??; + + Ok(()) +} + #[tokio::test] async fn creates_missing_codex_home_dir() -> Result<()> { skip_if_no_network!(Ok(())); @@ -195,6 +251,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> { forced_chatgpt_workspace_id: None, codex_streamlined_login: false, auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + login_success_page: LoginSuccessPage::Local, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -240,6 +297,7 @@ async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result< ]), codex_streamlined_login: false, auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + login_success_page: LoginSuccessPage::Local, }; let server = run_login_server(opts)?; let auth_url = Url::parse(&server.auth_url)?; @@ -280,6 +338,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), codex_streamlined_login: false, auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + login_success_page: LoginSuccessPage::Local, }; let server = run_login_server(opts)?; assert!( @@ -342,6 +401,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() forced_chatgpt_workspace_id: None, codex_streamlined_login: false, auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + login_success_page: LoginSuccessPage::Local, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -412,6 +472,7 @@ async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result< forced_chatgpt_workspace_id: None, codex_streamlined_login: false, auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + login_success_page: LoginSuccessPage::Local, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -561,6 +622,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { forced_chatgpt_workspace_id: None, codex_streamlined_login: false, auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + login_success_page: LoginSuccessPage::Local, }; let first_server = run_login_server(first_opts)?; @@ -584,6 +646,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { forced_chatgpt_workspace_id: None, codex_streamlined_login: false, auth_keyring_backend_kind: AuthKeyringBackendKind::Direct, + login_success_page: LoginSuccessPage::Local, }; let second_server = run_login_server(second_opts)?; diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 6c7ad3978f..903a1743ff 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -261,7 +261,9 @@ impl MemoryStartupContext { config.features.enabled(Feature::RuntimeMetrics), /*beta_features_header*/ None, config.features.enabled(Feature::ItemIds), + /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, + config.http_client_factory(), ); let mut client_session = model_client.new_session(); diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index 65517cbd43..61cd717d58 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -244,6 +244,7 @@ impl ModelProviderInfo { Some( AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens + | AuthMode::Headers | AuthMode::AgentIdentity | AuthMode::PersonalAccessToken ) diff --git a/codex-rs/model-provider-info/src/model_provider_info_tests.rs b/codex-rs/model-provider-info/src/model_provider_info_tests.rs index 5949f36bb8..9dbb23ecc1 100644 --- a/codex-rs/model-provider-info/src/model_provider_info_tests.rs +++ b/codex-rs/model-provider-info/src/model_provider_info_tests.rs @@ -148,6 +148,15 @@ fn test_personal_access_token_uses_chatgpt_codex_base_url() { assert_eq!(api_provider.base_url, CHATGPT_CODEX_BASE_URL); } +#[test] +fn test_header_auth_uses_chatgpt_codex_base_url() { + let api_provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None) + .to_api_provider(Some(AuthMode::Headers)) + .expect("OpenAI provider should build API provider"); + + assert_eq!(api_provider.base_url, CHATGPT_CODEX_BASE_URL); +} + #[test] fn test_supports_remote_compaction_for_azure_name() { let provider = ModelProviderInfo { diff --git a/codex-rs/model-provider/Cargo.toml b/codex-rs/model-provider/Cargo.toml index 29599c6c7e..b6ee6acbfc 100644 --- a/codex-rs/model-provider/Cargo.toml +++ b/codex-rs/model-provider/Cargo.toml @@ -16,7 +16,7 @@ workspace = true codex-api = { workspace = true } codex-agent-identity = { workspace = true } codex-aws-auth = { workspace = true } -codex-client = { workspace = true } +codex-http-client = { workspace = true } codex-feedback = { workspace = true } codex-login = { workspace = true } codex-model-provider-info = { workspace = true } diff --git a/codex-rs/model-provider/src/amazon_bedrock/auth.rs b/codex-rs/model-provider/src/amazon_bedrock/auth.rs index 6dd897050a..b9b318d9b1 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/auth.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/auth.rs @@ -6,9 +6,9 @@ use codex_api::SharedAuthProvider; use codex_aws_auth::AwsAuthContext; use codex_aws_auth::AwsAuthError; use codex_aws_auth::AwsRequestToSign; -use codex_client::Request; -use codex_client::RequestBody; -use codex_client::RequestCompression; +use codex_http_client::Request; +use codex_http_client::RequestBody; +use codex_http_client::RequestCompression; use codex_login::auth::BedrockApiKeyAuth; use codex_model_provider_info::ModelProviderAwsAuthInfo; use codex_protocol::error::CodexErr; diff --git a/codex-rs/model-provider/src/amazon_bedrock/mod.rs b/codex-rs/model-provider/src/amazon_bedrock/mod.rs index 108308c1bf..9fdf937bcb 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mod.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mod.rs @@ -69,6 +69,7 @@ impl AmazonBedrockModelProvider { CodexAuth::ApiKey(_) | CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::Headers(_) | CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => None, }) diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index 5b667e4549..bcd36f1912 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -7,6 +7,7 @@ use codex_agent_identity::authorization_header_for_agent_task; use codex_api::AgentIdentityTelemetry; use codex_api::AuthProvider; use codex_api::SharedAuthProvider; +use codex_login::AuthHeaders; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::auth::AgentIdentityAuth; @@ -109,6 +110,17 @@ impl AuthProvider for AgentIdentityAuthProvider { } } +#[derive(Clone, Debug)] +struct HeaderAuthProvider { + auth: AuthHeaders, +} + +impl AuthProvider for HeaderAuthProvider { + fn add_auth_headers(&self, headers: &mut HeaderMap) { + headers.extend(self.auth.headers().clone()); + } +} + // Some providers are meant to send no auth headers. Examples include local OSS // providers and custom test providers with `requires_openai_auth = false`. #[derive(Clone, Debug)] @@ -243,6 +255,7 @@ pub fn auth_provider_from_auth(auth: &CodexAuth) -> SharedAuthProvider { CodexAuth::AgentIdentity(auth) => { Arc::new(AgentIdentityAuthProvider { auth: auth.clone() }) } + CodexAuth::Headers(auth) => Arc::new(HeaderAuthProvider { auth: auth.clone() }), CodexAuth::BedrockApiKey(_) => unreachable!("{BEDROCK_API_KEY_UNSUPPORTED_MESSAGE}"), CodexAuth::ApiKey(_) | CodexAuth::Chatgpt(_) @@ -388,6 +401,21 @@ mod tests { assert!(auth.to_auth_headers().is_empty()); } + #[test] + fn header_auth_adds_predefined_headers() { + let mut expected = HeaderMap::new(); + expected.insert( + http::header::AUTHORIZATION, + HeaderValue::from_static("Bearer external"), + ); + expected.insert("x-external-auth", HeaderValue::from_static("enabled")); + let auth = CodexAuth::Headers(AuthHeaders::new(expected.clone())); + + let actual = auth_provider_from_auth(&auth).to_auth_headers(); + + assert_eq!(actual, expected); + } + #[test] fn openai_provider_rejects_bedrock_api_key_auth() { let provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None); diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 5f101e52cd..2b8cb67b97 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -277,6 +277,9 @@ impl ModelProvider for ConfiguredModelProvider { if auth_manager.refresh_failure_for_auth(&auth).is_some() { return None; } + if matches!(auth, CodexAuth::Headers(_)) { + return None; + } Some(auth) }) .map(|auth| match &auth { @@ -286,6 +289,7 @@ impl ModelProvider for ConfiguredModelProvider { } CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::Headers(_) | CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => { let email = auth.get_account_email(); diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index e7d1691a3a..f1678fe9f2 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -7,7 +7,6 @@ use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::ExternalAuth; use codex_login::ExternalAuthRefreshContext; -use codex_login::ExternalAuthTokens; use codex_login::TokenData; use codex_protocol::auth::AuthMode; use codex_protocol::openai_models::ModelsResponse; @@ -118,27 +117,15 @@ impl TestModelsEndpoint { struct TestExternalApiKeyAuth; impl ExternalAuth for TestExternalApiKeyAuth { - fn auth_mode(&self) -> AuthMode { - AuthMode::ApiKey - } - - fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, Option> { - Box::pin(async { - Ok(Some(ExternalAuthTokens::access_token_only( - "test-external-api-key", - ))) - }) + fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async { Ok(CodexAuth::from_api_key("test-external-api-key")) }) } fn refresh( &self, _context: ExternalAuthRefreshContext, - ) -> codex_login::ExternalAuthFuture<'_, ExternalAuthTokens> { - Box::pin(async { - Ok(ExternalAuthTokens::access_token_only( - "test-external-api-key", - )) - }) + ) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async { Ok(CodexAuth::from_api_key("test-external-api-key")) }) } } @@ -146,14 +133,14 @@ impl ExternalAuth for TestExternalApiKeyAuth { struct TestUnresolvedExternalApiKeyAuth; impl ExternalAuth for TestUnresolvedExternalApiKeyAuth { - fn auth_mode(&self) -> AuthMode { - AuthMode::ApiKey + fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async { Err(std::io::Error::other("unresolved test auth")) }) } fn refresh( &self, _context: ExternalAuthRefreshContext, - ) -> codex_login::ExternalAuthFuture<'_, ExternalAuthTokens> { + ) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { Box::pin(async { Err(std::io::Error::other("unresolved test auth")) }) } } @@ -867,7 +854,10 @@ async fn refresh_available_models_skips_network_when_external_api_key_overrides_ let codex_home = tempdir().expect("temp dir"); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - auth_manager.set_external_auth(Arc::new(TestExternalApiKeyAuth)); + auth_manager + .set_external_auth(Arc::new(TestExternalApiKeyAuth)) + .await + .expect("external API key auth should resolve"); let endpoint = TestAuthAwareModelsEndpoint::new( Some(Arc::clone(&auth_manager)), vec![vec![remote_model( @@ -907,7 +897,10 @@ async fn refresh_available_models_uses_cached_chatgpt_when_external_api_key_is_u let codex_home = tempdir().expect("temp dir"); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - auth_manager.set_external_auth(Arc::new(TestUnresolvedExternalApiKeyAuth)); + auth_manager + .set_external_auth(Arc::new(TestUnresolvedExternalApiKeyAuth)) + .await + .expect_err("unresolved external auth should be rejected"); let endpoint = TestAuthAwareModelsEndpoint::new( Some(Arc::clone(&auth_manager)), vec![vec![remote_model( @@ -1022,7 +1015,10 @@ async fn static_manager_reads_latest_auth_mode() { vec!["chatgpt-only", "api-model"] ); - auth_manager.set_external_auth(Arc::new(TestExternalApiKeyAuth)); + auth_manager + .set_external_auth(Arc::new(TestExternalApiKeyAuth)) + .await + .expect("external API key auth should resolve"); let api_models = manager.list_models(RefreshStrategy::Online).await; assert_eq!( diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index 573403b728..75d333efe3 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -54,14 +54,24 @@ pub fn with_config_overrides(mut model: ModelInfo, config: &ModelsManagerConfig) if let Some(base_instructions) = &config.base_instructions { model.base_instructions = base_instructions.clone(); - model.model_messages = None; + clear_instruction_messages(&mut model); } else if !config.personality_enabled { - model.model_messages = None; + clear_instruction_messages(&mut model); } model } +fn clear_instruction_messages(model: &mut ModelInfo) { + if let Some(model_messages) = &mut model.model_messages { + model_messages.instructions_template = None; + model_messages.instructions_variables = None; + if model_messages.approvals.is_none() { + model.model_messages = None; + } + } +} + /// Build a minimal fallback model descriptor for missing/unknown slugs. pub fn model_info_from_slug(slug: &str) -> ModelInfo { warn!("Unknown model {slug} is used. This will use fallback model metadata."); @@ -119,6 +129,7 @@ fn local_personality_messages_for_slug(slug: &str) -> Option { personality_friendly: Some(LOCAL_FRIENDLY_TEMPLATE.to_string()), personality_pragmatic: Some(LOCAL_PRAGMATIC_TEMPLATE.to_string()), }), + approvals: None, }), _ => None, } diff --git a/codex-rs/models-manager/src/model_info_tests.rs b/codex-rs/models-manager/src/model_info_tests.rs index 70ad3da8df..2240f0a391 100644 --- a/codex-rs/models-manager/src/model_info_tests.rs +++ b/codex-rs/models-manager/src/model_info_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::ModelsManagerConfig; +use codex_protocol::openai_models::ApprovalMessages; use pretty_assertions::assert_eq; #[test] @@ -44,6 +45,68 @@ fn reasoning_summaries_override_false_is_noop_when_model_is_false() { assert_eq!(updated, model); } +#[test] +fn base_instruction_override_preserves_catalog_approval_messages() { + let mut model = model_info_from_slug("unknown-model"); + let approvals = ApprovalMessages { + on_request: Some("user approvals".to_string()), + on_request_auto_review: Some("auto approvals".to_string()), + }; + model.model_messages = Some(ModelMessages { + instructions_template: Some("template".to_string()), + instructions_variables: Some(ModelInstructionsVariables { + personality_default: Some("default".to_string()), + personality_friendly: Some("friendly".to_string()), + personality_pragmatic: Some("pragmatic".to_string()), + }), + approvals: Some(approvals.clone()), + }); + let config = ModelsManagerConfig { + base_instructions: Some("override".to_string()), + ..Default::default() + }; + + let updated = with_config_overrides(model, &config); + + assert_eq!( + updated.model_messages, + Some(ModelMessages { + instructions_template: None, + instructions_variables: None, + approvals: Some(approvals), + }) + ); +} + +#[test] +fn disabled_personality_preserves_catalog_approval_messages() { + let mut model = model_info_from_slug("unknown-model"); + let approvals = ApprovalMessages { + on_request: Some("user approvals".to_string()), + on_request_auto_review: None, + }; + model.model_messages = Some(ModelMessages { + instructions_template: Some("template".to_string()), + instructions_variables: None, + approvals: Some(approvals.clone()), + }); + let config = ModelsManagerConfig { + personality_enabled: false, + ..Default::default() + }; + + let updated = with_config_overrides(model, &config); + + assert_eq!( + updated.model_messages, + Some(ModelMessages { + instructions_template: None, + instructions_variables: None, + approvals: Some(approvals), + }) + ); +} + #[test] fn model_context_window_override_clamps_to_max_context_window() { let mut model = model_info_from_slug("unknown-model"); diff --git a/codex-rs/network-proxy/src/attribution.rs b/codex-rs/network-proxy/src/attribution.rs new file mode 100644 index 0000000000..f526460fd6 --- /dev/null +++ b/codex-rs/network-proxy/src/attribution.rs @@ -0,0 +1,143 @@ +use crate::state::NetworkProxyState; +use rama_core::Service; +use rama_core::error::BoxError; +use rama_core::extensions::ExtensionsMut; +use rama_tcp::TcpStream; +use std::io; +use std::io::Write; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::AsyncReadExt; + +/// Internal handoff from the trusted Linux proxy bridge. +#[doc(hidden)] +pub const PROXY_ATTRIBUTION_TOKEN_ENV_KEY: &str = "CODEX_NETWORK_PROXY_ATTRIBUTION"; + +const ATTRIBUTION_FRAME_MAGIC: &[u8; 8] = b"\0CDXPXY1"; +const MAX_ATTRIBUTION_TOKEN_LEN: usize = 128; +const ATTRIBUTION_FRAME_TIMEOUT: Duration = Duration::from_secs(3); + +pub(crate) struct BindConnectionAttribution { + inner: S, + state: Arc, + environment_id: Option, +} + +impl BindConnectionAttribution { + pub(crate) fn new( + inner: S, + state: Arc, + environment_id: Option, + ) -> Self { + Self { + inner, + state, + environment_id, + } + } +} + +impl Service for BindConnectionAttribution +where + S: Service, + S::Error: Into, +{ + type Output = S::Output; + type Error = BoxError; + + async fn serve(&self, mut stream: TcpStream) -> Result { + let state = match read_attribution_token(&mut stream).await? { + Some(token) => self.state.for_execution_token(&token).ok_or_else(|| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "unknown network proxy attribution token", + ) + })?, + None => self.state.as_ref().clone(), + }; + if let Some(expected_environment_id) = self.environment_id.as_deref() + && state + .environment_id() + .is_some_and(|actual| actual != expected_environment_id) + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "network proxy attribution environment mismatch", + ) + .into()); + } + stream.extensions_mut().insert(Arc::new(state)); + self.inner.serve(stream).await.map_err(Into::into) + } +} + +async fn read_attribution_token(stream: &mut TcpStream) -> Result, BoxError> { + let mut marker = [0_u8; 1]; + let read = stream.stream.peek(&mut marker).await?; + if read == 0 { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "empty proxy connection").into()); + } + if marker[0] != ATTRIBUTION_FRAME_MAGIC[0] { + return Ok(None); + } + + let token = tokio::time::timeout(ATTRIBUTION_FRAME_TIMEOUT, async { + let mut magic = [0_u8; ATTRIBUTION_FRAME_MAGIC.len()]; + stream.read_exact(&mut magic).await?; + if &magic != ATTRIBUTION_FRAME_MAGIC { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid network proxy attribution frame", + )); + } + + let token_len = stream.read_u16().await? as usize; + if token_len == 0 || token_len > MAX_ATTRIBUTION_TOKEN_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid network proxy attribution token length", + )); + } + let mut token = vec![0_u8; token_len]; + stream.read_exact(&mut token).await?; + String::from_utf8(token).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "network proxy attribution token is not UTF-8", + ) + }) + }) + .await + .map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + "network proxy attribution frame timed out", + ) + })??; + + Ok(Some(token)) +} + +/// Writes the trusted bridge preface consumed by the shared proxy ingress. +#[doc(hidden)] +pub fn write_attribution_frame(writer: &mut impl Write, token: &str) -> io::Result<()> { + if token.is_empty() || token.len() > MAX_ATTRIBUTION_TOKEN_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid network proxy attribution token length", + )); + } + let token_len = u16::try_from(token.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "network proxy attribution token is too long", + ) + })?; + writer.write_all(ATTRIBUTION_FRAME_MAGIC)?; + writer.write_all(&token_len.to_be_bytes())?; + writer.write_all(token.as_bytes()) +} + +#[cfg(test)] +#[path = "attribution_tests.rs"] +mod tests; diff --git a/codex-rs/network-proxy/src/attribution_tests.rs b/codex-rs/network-proxy/src/attribution_tests.rs new file mode 100644 index 0000000000..e28110263c --- /dev/null +++ b/codex-rs/network-proxy/src/attribution_tests.rs @@ -0,0 +1,63 @@ +use super::BindConnectionAttribution; +use super::write_attribution_frame; +use crate::config::NetworkProxySettings; +use crate::runtime::network_proxy_state_for_policy; +use crate::state::NetworkProxyState; +use pretty_assertions::assert_eq; +use rama_core::Service; +use rama_core::error::BoxError; +use rama_core::extensions::ExtensionsRef; +use rama_core::service::service_fn; +use rama_tcp::TcpStream as RamaTcpStream; +use std::io; +use std::sync::Arc; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; +use tokio::net::TcpStream; + +#[test] +fn attribution_frame_has_bounded_binary_prefix() -> io::Result<()> { + let mut frame = Vec::new(); + write_attribution_frame(&mut frame, "token-1")?; + + assert_eq!(&frame[..8], b"\0CDXPXY1"); + assert_eq!(u16::from_be_bytes([frame[8], frame[9]]), 7); + assert_eq!(&frame[10..], b"token-1"); + Ok(()) +} + +#[tokio::test] +async fn framed_connection_receives_registered_execution_state() -> Result<(), BoxError> { + let state = Arc::new(network_proxy_state_for_policy( + NetworkProxySettings::default(), + )); + state.register_execution("token-1", "local", "execution-1"); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let client = tokio::spawn(async move { + let mut stream = TcpStream::connect(addr).await?; + let mut frame = Vec::new(); + write_attribution_frame(&mut frame, "token-1")?; + stream.write_all(&frame).await + }); + + let (stream, _) = listener.accept().await?; + let service = BindConnectionAttribution::new( + service_fn(|stream: RamaTcpStream| async move { + let state = stream.extensions().get::>().cloned(); + Ok::<_, io::Error>(state) + }), + state, + Some("local".to_string()), + ); + let actual = service + .serve(RamaTcpStream::new(stream)) + .await? + .expect("connection state"); + client.await??; + + assert_eq!(actual.environment_id(), Some("local")); + assert_eq!(actual.execution_id().as_deref(), Some("execution-1")); + Ok(()) +} diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index 180ef41c51..59cad9c20a 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -1,3 +1,4 @@ +use crate::attribution::BindConnectionAttribution; use crate::config::NetworkMode; use crate::connect_policy::TargetCheckedTcpConnector; use crate::mitm; @@ -39,7 +40,6 @@ use rama_core::error::ErrorExt as _; use rama_core::error::OpaqueError; use rama_core::extensions::ExtensionsMut; use rama_core::extensions::ExtensionsRef; -use rama_core::layer::AddInputExtensionLayer; use rama_core::service::service_fn; use rama_core::stream::Stream; use rama_http::Body; @@ -159,7 +159,11 @@ async fn run_http_proxy_with_listener( info!("HTTP proxy listening on {addr}"); listener - .serve(AddInputExtensionLayer::new(state).into_layer(http_service)) + .serve(BindConnectionAttribution::new( + http_service, + state, + environment_id, + )) .await; Ok(()) } diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 82ab48ba89..6bf6d8eb38 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -1,5 +1,6 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] +mod attribution; mod certs; mod config; mod connect_policy; @@ -18,6 +19,8 @@ mod socks5; mod state; mod upstream; +pub use attribution::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; +pub use attribution::write_attribution_frame; pub use certs::CUSTOM_CA_ENV_KEYS; pub use certs::is_managed_mitm_ca_trust_bundle_path; pub use config::NetworkDomainPermission; diff --git a/codex-rs/network-proxy/src/network_policy.rs b/codex-rs/network-proxy/src/network_policy.rs index e5792a07c0..956aa6f682 100644 --- a/codex-rs/network-proxy/src/network_policy.rs +++ b/codex-rs/network-proxy/src/network_policy.rs @@ -84,6 +84,7 @@ pub struct NetworkPolicyRequest { pub method: Option, pub command: Option, pub exec_policy_hint: Option, + pub execution_id: Option, } pub struct NetworkPolicyRequestArgs { @@ -118,6 +119,7 @@ impl NetworkPolicyRequest { method, command, exec_policy_hint, + execution_id: None, } } } @@ -199,6 +201,7 @@ fn emit_non_domain_policy_decision_audit_event( args: BlockDecisionAuditEventArgs<'_>, decision: &'static str, ) { + let execution_id = state.execution_id(); emit_policy_audit_event( state, PolicyAuditEventArgs { @@ -211,6 +214,7 @@ fn emit_non_domain_policy_decision_audit_event( server_port: args.server_port, method: args.method, client_addr: args.client_addr, + execution_id: execution_id.as_deref(), policy_override: false, }, ); @@ -226,6 +230,7 @@ struct PolicyAuditEventArgs<'a> { server_port: u16, method: Option<&'a str>, client_addr: Option<&'a str>, + execution_id: Option<&'a str>, policy_override: bool, } @@ -254,6 +259,7 @@ fn emit_policy_audit_event(state: &NetworkProxyState, args: PolicyAuditEventArgs server.port = args.server_port, http.request.method = args.method.unwrap_or(DEFAULT_METHOD), client.address = args.client_addr.unwrap_or(DEFAULT_CLIENT_ADDRESS), + execution.id = args.execution_id, network.policy.override = args.policy_override, ); } @@ -295,12 +301,20 @@ pub(crate) async fn evaluate_host_policy( decider: Option<&Arc>, request: &NetworkPolicyRequest, ) -> Result { + let execution_id = state.execution_id(); let host_decision = state.host_blocked(&request.host, request.port).await?; let (decision, policy_override) = match host_decision { HostBlockDecision::Allowed => (NetworkDecision::Allow, false), HostBlockDecision::Blocked(HostBlockReason::NotAllowed) => { if let Some(decider) = decider { - let decider_decision = map_decider_decision(decider.decide(request.clone()).await); + let mut request = request.clone(); + if request.environment_id.is_none() + && let Some(environment_id) = state.environment_id() + { + request.environment_id = Some(environment_id.to_string()); + } + request.execution_id = execution_id.clone(); + let decider_decision = map_decider_decision(decider.decide(request).await); let policy_override = matches!(decider_decision, NetworkDecision::Allow); (decider_decision, policy_override) } else { @@ -355,6 +369,7 @@ pub(crate) async fn evaluate_host_policy( server_port: request.port, method: request.method.as_deref(), client_addr: request.client_addr.as_deref(), + execution_id: execution_id.as_deref(), policy_override, }, ); @@ -679,6 +694,46 @@ mod tests { ); } + #[tokio::test(flavor = "current_thread")] + async fn evaluate_host_policy_emits_execution_id_for_baseline_allow() { + let state = network_proxy_state_for_policy({ + let mut network = NetworkProxySettings::default(); + network.set_allowed_domains(vec!["example.com".to_string()]); + network + }); + state.register_execution("token-baseline-allow", "local", "execution-baseline-allow"); + let state = state + .for_execution_token("token-baseline-allow") + .expect("expected registered execution"); + let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs { + protocol: NetworkProtocol::Http, + host: "example.com".to_string(), + port: 80, + environment_id: None, + client_addr: None, + method: None, + command: None, + exec_policy_hint: None, + }); + + let (decision, events) = capture_events(|| async { + evaluate_host_policy(&state, /*decider*/ None, &request) + .await + .unwrap() + }) + .await; + assert_eq!(decision, NetworkDecision::Allow); + + let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME) + .expect("expected policy decision audit event"); + assert_eq!(event.field("network.policy.decision"), Some("allow")); + assert_eq!( + event.field("execution.id"), + Some("execution-baseline-allow") + ); + assert_ne!(event.field("execution.id"), Some("token-baseline-allow")); + } + #[tokio::test(flavor = "current_thread")] async fn evaluate_host_policy_emits_domain_event_for_baseline_deny() { let state = network_proxy_state_for_policy({ @@ -687,6 +742,10 @@ mod tests { network.set_denied_domains(vec!["blocked.com".to_string()]); network }); + state.register_execution("token-baseline-deny", "local", "execution-baseline-deny"); + let state = state + .for_execution_token("token-baseline-deny") + .expect("expected registered execution"); let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs { protocol: NetworkProtocol::Http, host: "blocked.com".to_string(), @@ -724,6 +783,8 @@ mod tests { assert_eq!(event.field("network.policy.override"), Some("false")); assert_eq!(event.field("http.request.method"), Some("GET")); assert_eq!(event.field("client.address"), Some("127.0.0.1:1234")); + assert_eq!(event.field("execution.id"), Some("execution-baseline-deny")); + assert_ne!(event.field("execution.id"), Some("token-baseline-deny")); } #[tokio::test(flavor = "current_thread")] diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index a338744b68..476c68aca8 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -1,3 +1,6 @@ +mod execution_scope; + +use crate::attribution::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; use crate::config; use crate::credential_broker::BROKERED_CREDENTIALS_ENV_KEY; use crate::credential_broker::CREDENTIAL_BROKER_ACTIVE_ENV_KEY; @@ -23,6 +26,8 @@ use std::sync::RwLock; use tokio::task::JoinHandle; use tracing::warn; +use self::execution_scope::ExecutionScope; + #[derive(Debug, Clone, Parser)] #[command(name = "codex-network-proxy", about = "Codex network sandbox proxy")] pub struct Args {} @@ -233,6 +238,7 @@ impl NetworkProxyBuilder { reserved_listeners, policy_decider: self.policy_decider, environment_proxies: Arc::new(Mutex::new(HashMap::new())), + execution_scope: None, }) } } @@ -370,6 +376,7 @@ pub struct NetworkProxy { reserved_listeners: Option>, policy_decider: Option>, environment_proxies: Arc>>, + execution_scope: Option>, } impl std::fmt::Debug for NetworkProxy { @@ -424,6 +431,7 @@ pub const PROXY_ENV_KEYS: &[&str] = &[ CREDENTIAL_BROKER_ACTIVE_ENV_KEY, BROKERED_CREDENTIALS_ENV_KEY, ALLOW_LOCAL_BINDING_ENV_KEY, + PROXY_ATTRIBUTION_TOKEN_ENV_KEY, ELECTRON_GET_USE_PROXY_ENV_KEY, NODE_USE_ENV_PROXY_ENV_KEY, "HTTP_PROXY", @@ -697,6 +705,12 @@ impl NetworkProxy { runtime_settings.mitm_ca_trust_bundle.as_ref(), ); self.state.virtualize_child_credentials(&mut env); + if let Some(execution_scope) = self.execution_scope.as_ref() { + env.insert( + PROXY_ATTRIBUTION_TOKEN_ENV_KEY.to_string(), + execution_scope.attribution_token.clone(), + ); + } let mut loopback_ports = [ Some(addrs.http_addr), self.socks_enabled.then_some(addrs.socks_addr), @@ -778,6 +792,14 @@ impl NetworkProxy { } fn environment_proxy_addrs(&self, environment_id: &str) -> Result { + if let Some(execution_scope) = self.execution_scope.as_ref() { + anyhow::ensure!( + execution_scope.environment_id == environment_id, + "execution-scoped network proxy belongs to environment `{}`, not `{environment_id}`", + execution_scope.environment_id + ); + } + let mut proxies = self .environment_proxies .lock() @@ -895,6 +917,10 @@ impl NetworkProxy { } pub async fn run(&self) -> Result { + anyhow::ensure!( + self.execution_scope.is_none(), + "execution-scoped network proxy is already running" + ); let current_cfg = self.state.current_cfg().await?; if !current_cfg.network.enabled { warn!("network.enabled is false; skipping proxy listeners"); diff --git a/codex-rs/network-proxy/src/proxy/execution_scope.rs b/codex-rs/network-proxy/src/proxy/execution_scope.rs new file mode 100644 index 0000000000..37068625d4 --- /dev/null +++ b/codex-rs/network-proxy/src/proxy/execution_scope.rs @@ -0,0 +1,38 @@ +use super::*; + +pub(super) struct ExecutionScope { + pub(super) environment_id: String, + pub(super) attribution_token: String, + state: Arc, +} + +impl Drop for ExecutionScope { + fn drop(&mut self) { + self.state.unregister_execution(&self.attribution_token); + } +} + +impl NetworkProxy { + /// Returns a proxy that attributes trusted bridge connections to one execution. + pub fn for_execution( + &self, + environment_id: &str, + execution_id: &str, + attribution_token: String, + ) -> Result { + anyhow::ensure!( + self.execution_scope.is_none(), + "cannot scope an execution-scoped network proxy" + ); + self.state + .register_execution(&attribution_token, environment_id, execution_id); + + let mut proxy = self.clone(); + proxy.execution_scope = Some(Arc::new(ExecutionScope { + environment_id: environment_id.to_string(), + attribution_token, + state: Arc::clone(&self.state), + })); + Ok(proxy) + } +} diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index b486ab057a..a51f1d8f16 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -33,6 +33,7 @@ use std::net::SocketAddr; use std::path::Path; use std::pin::Pin; use std::sync::Arc; +use std::sync::Mutex; use std::time::Duration; use time::OffsetDateTime; use tokio::net::lookup_host; @@ -96,6 +97,8 @@ pub struct BlockedRequest { pub method: Option, pub mode: Option, pub protocol: String, + #[serde(skip)] + pub execution_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub decision: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -137,6 +140,7 @@ impl BlockedRequest { method, mode, protocol, + execution_id: None, decision, source, port, @@ -211,6 +215,9 @@ pub struct NetworkProxyState { blocked_request_observer: Arc>>>, credential_broker: CredentialBroker, audit_metadata: NetworkProxyAuditMetadata, + execution_attributions: Arc>>, + environment_id: Option>, + execution_id: Option>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -220,6 +227,12 @@ pub(crate) enum HostMitmRequirement { Always, } +#[derive(Clone)] +struct ExecutionAttribution { + environment_id: String, + execution_id: String, +} + impl std::fmt::Debug for NetworkProxyState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // Avoid logging internal state (config contents, derived globsets, etc.) which can be noisy @@ -236,6 +249,9 @@ impl Clone for NetworkProxyState { blocked_request_observer: self.blocked_request_observer.clone(), credential_broker: self.credential_broker.clone(), audit_metadata: self.audit_metadata.clone(), + execution_attributions: self.execution_attributions.clone(), + environment_id: self.environment_id.clone(), + execution_id: self.execution_id.clone(), } } } @@ -287,9 +303,59 @@ impl NetworkProxyState { reloader, blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)), audit_metadata, + execution_attributions: Arc::new(Mutex::new(HashMap::new())), + environment_id: None, + execution_id: None, } } + pub(crate) fn register_execution( + &self, + attribution_token: &str, + environment_id: &str, + execution_id: &str, + ) { + self.execution_attributions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + attribution_token.to_string(), + ExecutionAttribution { + environment_id: environment_id.to_string(), + execution_id: execution_id.to_string(), + }, + ); + } + + pub(crate) fn unregister_execution(&self, attribution_token: &str) { + self.execution_attributions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(attribution_token); + } + + pub(crate) fn for_execution_token(&self, token: &str) -> Option { + let attribution = self + .execution_attributions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(token)? + .clone(); + Some(Self { + environment_id: Some(attribution.environment_id.into()), + execution_id: Some(attribution.execution_id.into()), + ..self.clone() + }) + } + + pub(crate) fn environment_id(&self) -> Option<&str> { + self.environment_id.as_deref() + } + + pub(crate) fn execution_id(&self) -> Option { + self.execution_id.as_deref().map(str::to_string) + } + pub async fn set_blocked_request_observer( &self, blocked_request_observer: Option>, @@ -460,8 +526,9 @@ impl NetworkProxyState { } } - pub async fn record_blocked(&self, entry: BlockedRequest) -> Result<()> { + pub async fn record_blocked(&self, mut entry: BlockedRequest) -> Result<()> { self.reload_if_needed().await?; + entry.execution_id = self.execution_id(); let blocked_for_observer = entry.clone(); let blocked_request_observer = self.blocked_request_observer.read().await.clone(); let violation_line = blocked_request_violation_log_line(&entry); @@ -1306,6 +1373,7 @@ mod tests { method: Some("GET".to_string()), mode: Some(NetworkMode::Full), protocol: "http".to_string(), + execution_id: None, decision: Some("ask".to_string()), source: Some("decider".to_string()), port: Some(80), diff --git a/codex-rs/network-proxy/src/socks5.rs b/codex-rs/network-proxy/src/socks5.rs index 9561877043..d1c193198f 100644 --- a/codex-rs/network-proxy/src/socks5.rs +++ b/codex-rs/network-proxy/src/socks5.rs @@ -1,3 +1,4 @@ +use crate::attribution::BindConnectionAttribution; use crate::config::NetworkMode; use crate::connect_policy::TargetCheckedTcpConnector; use crate::mitm; @@ -23,13 +24,11 @@ use crate::state::BlockedRequestArgs; use crate::state::NetworkProxyState; use anyhow::Context as _; use anyhow::Result; -use rama_core::Layer; use rama_core::Service; use rama_core::error::BoxError; use rama_core::extensions::Extensions; use rama_core::extensions::ExtensionsMut; use rama_core::extensions::ExtensionsRef; -use rama_core::layer::AddInputExtensionLayer; use rama_core::service::service_fn; use rama_net::address::HostWithPort; use rama_net::client::EstablishedClientConnection; @@ -165,11 +164,15 @@ async fn run_socks5_with_listener( })); let socks_acceptor = base.with_udp_associator(udp_relay); listener - .serve(AddInputExtensionLayer::new(state).into_layer(socks_acceptor)) + .serve(BindConnectionAttribution::new( + socks_acceptor, + state, + environment_id, + )) .await; } else { listener - .serve(AddInputExtensionLayer::new(state).into_layer(base)) + .serve(BindConnectionAttribution::new(base, state, environment_id)) .await; } Ok(()) diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 440f8f04a5..c470f8dc7c 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1195,6 +1195,7 @@ impl SessionTelemetry { ResponseEvent::OutputTextDelta(_) => "text_delta".into(), ResponseEvent::ToolCallInputDelta { .. } => "tool_input_delta".into(), ResponseEvent::ReasoningSummaryDelta { .. } => "reasoning_summary_delta".into(), + ResponseEvent::ReasoningSummaryDone { .. } => "reasoning_summary_done".into(), ResponseEvent::ReasoningContentDelta { .. } => "reasoning_content_delta".into(), ResponseEvent::ReasoningSummaryPartAdded { .. } => { "reasoning_summary_part_added".into() diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index 87ccbbfeee..65b034cc31 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -59,6 +59,7 @@ impl From for TelemetryAuthMode { AuthMode::ApiKey | AuthMode::BedrockApiKey => Self::ApiKey, AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens + | AuthMode::Headers | AuthMode::AgentIdentity | AuthMode::PersonalAccessToken => Self::Chatgpt, } diff --git a/codex-rs/prompts/src/lib.rs b/codex-rs/prompts/src/lib.rs index 5a30358393..c598b2d765 100644 --- a/codex-rs/prompts/src/lib.rs +++ b/codex-rs/prompts/src/lib.rs @@ -12,6 +12,7 @@ pub use compact::SUMMARY_PREFIX; pub use goals::budget_limit_prompt; pub use goals::continuation_prompt; pub use goals::objective_updated_prompt; +pub use permissions_instructions::ApprovalPromptContext; pub use permissions_instructions::PermissionsInstructions; pub use realtime::BACKEND_PROMPT; pub use realtime::END_INSTRUCTIONS; diff --git a/codex-rs/prompts/src/permissions_instructions.rs b/codex-rs/prompts/src/permissions_instructions.rs index c382b804a0..1c19d008cf 100644 --- a/codex-rs/prompts/src/permissions_instructions.rs +++ b/codex-rs/prompts/src/permissions_instructions.rs @@ -4,6 +4,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::SandboxMode; use codex_protocol::models::PermissionProfile; use codex_protocol::models::format_allow_prefixes; +use codex_protocol::openai_models::ApprovalMessages; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; @@ -47,6 +48,7 @@ static SANDBOX_MODE_READ_ONLY_TEMPLATE: LazyLock