From 3f6157004419e21547962670026c6f6001d06fe8 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Mon, 6 Jul 2026 16:58:05 -0700 Subject: [PATCH 1/9] [codex] bundle code mode host in release packages (#30202) ## Summary - build, strip, sign, and publish `codex-code-mode-host` with the primary Codex release binaries on Linux, macOS, and Windows - place the host beside `codex[.exe]` in canonical package archives, macOS DMGs, and the legacy Linux bundle so the runtime's sibling lookup succeeds - preserve and validate the host through standalone installers and Python runtime wheel staging - add package-builder coverage for source selection and the resulting package layout ## Why The process-owned code-mode client launches `codex-code-mode-host` as a sibling of the running Codex executable. Release artifacts currently build and bundle `codex` without that host, so code mode cannot start from installed packages. --- .../scripts/build-codex-package-archive.sh | 12 ++++ .github/workflows/rust-release-windows.yml | 10 +-- .github/workflows/rust-release.yml | 65 ++++++++++++------- scripts/codex_package/README.md | 7 +- scripts/codex_package/cargo.py | 14 +++- scripts/codex_package/cli.py | 14 ++++ scripts/codex_package/layout.py | 6 ++ scripts/codex_package/targets.py | 1 + scripts/codex_package/test_cargo.py | 21 ++++++ scripts/codex_package/test_layout.py | 56 ++++++++++++++++ scripts/install/install.ps1 | 1 + scripts/install/install.sh | 6 +- sdk/python/scripts/update_sdk_artifacts.py | 7 ++ .../test_artifact_workflow_and_binaries.py | 3 + 14 files changed, 193 insertions(+), 30 deletions(-) create mode 100644 scripts/codex_package/test_layout.py 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/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 7069b6ca8f..31d832fcbe 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -7,7 +7,7 @@ on: # 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 +28,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 +56,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 diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 49d24abc18..93f03e63d1 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 @@ -615,22 +624,22 @@ jobs: - target: aarch64-apple-darwin bundle: primary artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-code-mode-host codex-responses-api-proxy" build_dmg: "true" - target: aarch64-apple-darwin bundle: app-server artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" + binaries: "codex-app-server codex-code-mode-host" build_dmg: "false" - target: x86_64-apple-darwin bundle: primary artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-code-mode-host codex-responses-api-proxy" build_dmg: "true" - target: x86_64-apple-darwin bundle: app-server artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" + binaries: "codex-app-server codex-code-mode-host" build_dmg: "false" steps: @@ -707,6 +716,11 @@ jobs: mkdir -p "$dest" for binary in ${{ matrix.binaries }}; do + # Both variants package the host, but only the primary bundle publishes + # standalone binary archives to avoid duplicate release asset names. + if [[ "${{ matrix.bundle }}" == "app-server" && "$binary" == "codex-code-mode-host" ]]; then + continue + fi cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" done @@ -911,22 +925,22 @@ jobs: - target: aarch64-apple-darwin bundle: primary artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-code-mode-host codex-responses-api-proxy" verify_dmg: "true" - target: aarch64-apple-darwin bundle: app-server artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" + binaries: "codex-app-server codex-code-mode-host" verify_dmg: "false" - target: x86_64-apple-darwin bundle: primary artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-code-mode-host codex-responses-api-proxy" verify_dmg: "true" - target: x86_64-apple-darwin bundle: app-server artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" + binaries: "codex-app-server codex-code-mode-host" verify_dmg: "false" steps: @@ -983,6 +997,12 @@ jobs: binary_path="${RUNNER_TEMP}/signed-binaries/${binary}" verify_signed_binary "$binary_path" + # The app-server package contains the host, but its standalone archives + # are omitted above to avoid duplicate release asset names. + if [[ "${{ matrix.bundle }}" == "app-server" && "$binary" == "codex-code-mode-host" ]]; then + continue + fi + direct_archive_dir="${RUNNER_TEMP}/direct-archive-${binary}-${target}" rm -rf "$direct_archive_dir" mkdir -p "$direct_archive_dir" @@ -1014,6 +1034,7 @@ jobs: mkdir -p "$package_dir" tar -xzf "${packaged_dir}/${package_stem}-${target}.tar.gz" -C "$package_dir" verify_signed_binary "${package_dir}/bin/${package_entrypoint}" + verify_signed_binary "${package_dir}/bin/codex-code-mode-host" if [[ "${{ matrix.verify_dmg }}" != "true" ]]; then exit 0 diff --git a/scripts/codex_package/README.md b/scripts/codex_package/README.md index b76c18c3c2..7850bbd354 100644 --- a/scripts/codex_package/README.md +++ b/scripts/codex_package/README.md @@ -10,7 +10,8 @@ The builder creates a canonical Codex package directory: . ├── codex-package.json ├── bin -│ └── [.exe] +│ ├── [.exe] +│ └── codex-code-mode-host[.exe] ├── codex-resources │ ├── bwrap # Linux only │ ├── zsh/bin/zsh # supported Unix targets only @@ -40,6 +41,7 @@ grouped `cargo build` command per package when they are needed and no prebuilt override was provided: - all targets: the selected entrypoint, unless `--entrypoint-bin` is provided +- all targets: `codex-code-mode-host`, unless `--code-mode-host-bin` is provided - Linux targets: `bwrap`, unless `--bwrap-bin` is provided - Windows targets: `codex-command-runner` and `codex-windows-sandbox-setup`, unless the corresponding prebuilt helper flags are provided @@ -50,6 +52,9 @@ explicit target. Release jobs that already built and signed/notarized the entrypoint should pass `--entrypoint-bin` so the package contains that exact binary instead of rebuilding it. +Release jobs should likewise pass `--code-mode-host-bin` so the package contains +the signed host executable beside the signed entrypoint. + Release jobs that already built package resource binaries should also pass the corresponding resource flags: `--bwrap-bin` for Linux packages, and `--codex-command-runner-bin` plus `--codex-windows-sandbox-setup-bin` for diff --git a/scripts/codex_package/cargo.py b/scripts/codex_package/cargo.py index 208d85d174..f759e44762 100644 --- a/scripts/codex_package/cargo.py +++ b/scripts/codex_package/cargo.py @@ -17,6 +17,7 @@ CODEX_RS_ROOT = REPO_ROOT / "codex-rs" @dataclass(frozen=True) class SourceBuildOutputs: entrypoint_bin: Path + code_mode_host_bin: Path bwrap_bin: Path | None codex_command_runner_bin: Path | None codex_windows_sandbox_setup_bin: Path | None @@ -29,6 +30,7 @@ def build_source_binaries( cargo: str, profile: str, entrypoint_bin: Path | None, + code_mode_host_bin: Path | None, bwrap_bin: Path | None, codex_command_runner_bin: Path | None, codex_windows_sandbox_setup_bin: Path | None, @@ -43,6 +45,7 @@ def build_source_binaries( spec, variant, build_entrypoint=entrypoint_bin is None, + build_code_mode_host=code_mode_host_bin is None, build_bwrap=spec.is_linux and bwrap_bin is None, build_codex_command_runner=spec.is_windows and codex_command_runner_bin is None, build_codex_windows_sandbox_setup=spec.is_windows @@ -61,7 +64,7 @@ def build_source_binaries( cmd.extend(["--bin", binary]) cargo_env = None - if entrypoint_bin is None: + if entrypoint_bin is None or code_mode_host_bin is None: codex_v8_env = resolve_codex_v8_cargo_env(spec) if codex_v8_env: cargo_env = {**os.environ, **codex_v8_env} @@ -80,6 +83,11 @@ def build_source_binaries( entrypoint_bin, output_dir / variant.entrypoint_name(spec), ), + code_mode_host_bin=( + code_mode_host_bin.resolve() + if code_mode_host_bin is not None + else output_dir / f"codex-code-mode-host{spec.exe_suffix}" + ), bwrap_bin=resolve_output_path( bwrap_bin, output_dir / "bwrap" if spec.is_linux else None, @@ -102,6 +110,7 @@ def source_binaries_for_target( variant: PackageVariant, *, build_entrypoint: bool, + build_code_mode_host: bool, build_bwrap: bool, build_codex_command_runner: bool, build_codex_windows_sandbox_setup: bool, @@ -109,6 +118,8 @@ def source_binaries_for_target( binaries = [] if build_entrypoint: binaries.append(variant.cargo_bin) + if build_code_mode_host: + binaries.append("codex-code-mode-host") if build_bwrap: binaries.append("bwrap") if build_codex_command_runner: @@ -174,6 +185,7 @@ def cargo_profile_dirname(profile: str) -> str: def validate_source_outputs(outputs: SourceBuildOutputs) -> None: for path in [ outputs.entrypoint_bin, + outputs.code_mode_host_bin, outputs.bwrap_bin, outputs.codex_command_runner_bin, outputs.codex_windows_sandbox_setup_bin, diff --git a/scripts/codex_package/cli.py b/scripts/codex_package/cli.py index 1dd276827e..f461d0df43 100644 --- a/scripts/codex_package/cli.py +++ b/scripts/codex_package/cli.py @@ -82,6 +82,14 @@ def parse_args() -> argparse.Namespace: "variant. If omitted, the entrypoint is built with Cargo." ), ) + parser.add_argument( + "--code-mode-host-bin", + type=Path, + help=( + "Optional prebuilt codex-code-mode-host executable. If omitted, " + "the host is built with Cargo." + ), + ) parser.add_argument( "--bwrap-bin", type=Path, @@ -148,6 +156,11 @@ def main() -> int: "prebuilt entrypoint executable", "--entrypoint-bin", ), + code_mode_host_bin=resolve_optional_input_path( + args.code_mode_host_bin, + "prebuilt code-mode host executable", + "--code-mode-host-bin", + ), bwrap_bin=resolve_optional_input_path( args.bwrap_bin, "prebuilt Linux bwrap executable", @@ -167,6 +180,7 @@ def main() -> int: version = read_workspace_version() inputs = PackageInputs( entrypoint_bin=source_outputs.entrypoint_bin, + code_mode_host_bin=source_outputs.code_mode_host_bin, rg_bin=resolve_rg_bin(spec, args.rg_bin), zsh_bin=resolve_zsh_bin(spec, args.zsh_manifest), bwrap_bin=source_outputs.bwrap_bin, diff --git a/scripts/codex_package/layout.py b/scripts/codex_package/layout.py index 63598672ea..f4fef6912c 100644 --- a/scripts/codex_package/layout.py +++ b/scripts/codex_package/layout.py @@ -51,6 +51,11 @@ def build_package_dir( bin_dir / entrypoint_name, is_windows=spec.is_windows, ) + copy_executable( + inputs.code_mode_host_bin, + bin_dir / f"codex-code-mode-host{spec.exe_suffix}", + is_windows=spec.is_windows, + ) copy_executable(inputs.rg_bin, path_dir / spec.rg_name, is_windows=spec.is_windows) if inputs.zsh_bin is not None: @@ -130,6 +135,7 @@ def validate_package_dir( required_files = [ Path("bin") / variant.entrypoint_name(spec), + Path("bin") / f"codex-code-mode-host{spec.exe_suffix}", Path("codex-path") / spec.rg_name, ] executable_files = list(required_files) diff --git a/scripts/codex_package/targets.py b/scripts/codex_package/targets.py index 8307a3e630..016202cf5f 100644 --- a/scripts/codex_package/targets.py +++ b/scripts/codex_package/targets.py @@ -39,6 +39,7 @@ class PackageVariant: @dataclass(frozen=True) class PackageInputs: entrypoint_bin: Path + code_mode_host_bin: Path rg_bin: Path zsh_bin: Path | None bwrap_bin: Path | None diff --git a/scripts/codex_package/test_cargo.py b/scripts/codex_package/test_cargo.py index 6185f8b8b3..1a4a572b9b 100644 --- a/scripts/codex_package/test_cargo.py +++ b/scripts/codex_package/test_cargo.py @@ -20,6 +20,7 @@ class SourceBinariesForTargetTest(unittest.TestCase): TARGET_SPECS["aarch64-apple-darwin"], PACKAGE_VARIANTS["codex"], build_entrypoint=False, + build_code_mode_host=False, build_bwrap=False, build_codex_command_runner=False, build_codex_windows_sandbox_setup=False, @@ -35,6 +36,7 @@ class SourceBinariesForTargetTest(unittest.TestCase): TARGET_SPECS["x86_64-unknown-linux-musl"], PACKAGE_VARIANTS["codex"], build_entrypoint=False, + build_code_mode_host=False, build_bwrap=False, build_codex_command_runner=False, build_codex_windows_sandbox_setup=False, @@ -50,6 +52,7 @@ class SourceBinariesForTargetTest(unittest.TestCase): TARGET_SPECS["x86_64-pc-windows-msvc"], PACKAGE_VARIANTS["codex"], build_entrypoint=False, + build_code_mode_host=False, build_bwrap=False, build_codex_command_runner=False, build_codex_windows_sandbox_setup=False, @@ -63,6 +66,7 @@ class SourceBinariesForTargetTest(unittest.TestCase): TARGET_SPECS["x86_64-pc-windows-msvc"], PACKAGE_VARIANTS["codex"], build_entrypoint=False, + build_code_mode_host=False, build_bwrap=False, build_codex_command_runner=True, build_codex_windows_sandbox_setup=True, @@ -70,10 +74,25 @@ class SourceBinariesForTargetTest(unittest.TestCase): ["codex-command-runner", "codex-windows-sandbox-setup"], ) + def test_missing_code_mode_host_is_built_for_app_server(self) -> None: + self.assertEqual( + source_binaries_for_target( + TARGET_SPECS["aarch64-apple-darwin"], + PACKAGE_VARIANTS["codex-app-server"], + build_entrypoint=False, + build_code_mode_host=True, + build_bwrap=False, + build_codex_command_runner=False, + build_codex_windows_sandbox_setup=False, + ), + ["codex-code-mode-host"], + ) + def test_build_uses_prebuilt_windows_helpers_without_running_cargo(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) entrypoint = touch_file(root / "codex.exe") + code_mode_host = touch_file(root / "codex-code-mode-host.exe") command_runner = touch_file(root / "codex-command-runner.exe") sandbox_setup = touch_file(root / "codex-windows-sandbox-setup.exe") @@ -83,12 +102,14 @@ class SourceBinariesForTargetTest(unittest.TestCase): cargo=str(root / "cargo-that-should-not-run"), profile="release", entrypoint_bin=entrypoint, + code_mode_host_bin=code_mode_host, bwrap_bin=None, codex_command_runner_bin=command_runner, codex_windows_sandbox_setup_bin=sandbox_setup, ) self.assertEqual(outputs.entrypoint_bin, entrypoint) + self.assertEqual(outputs.code_mode_host_bin, code_mode_host) self.assertEqual(outputs.codex_command_runner_bin, command_runner) self.assertEqual(outputs.codex_windows_sandbox_setup_bin, sandbox_setup) diff --git a/scripts/codex_package/test_layout.py b/scripts/codex_package/test_layout.py new file mode 100644 index 0000000000..157bce5e9c --- /dev/null +++ b/scripts/codex_package/test_layout.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from codex_package.layout import build_package_dir +from codex_package.layout import validate_package_dir +from codex_package.targets import PACKAGE_VARIANTS +from codex_package.targets import PackageInputs +from codex_package.targets import TARGET_SPECS + + +class PackageLayoutTest(unittest.TestCase): + def test_app_server_package_places_code_mode_host_beside_entrypoint(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + package_dir = root / "package" + package_dir.mkdir() + inputs = PackageInputs( + entrypoint_bin=touch_executable(root / "codex-app-server"), + code_mode_host_bin=touch_executable(root / "codex-code-mode-host"), + rg_bin=touch_executable(root / "rg"), + zsh_bin=None, + bwrap_bin=touch_executable(root / "bwrap"), + codex_command_runner_bin=None, + codex_windows_sandbox_setup_bin=None, + ) + + build_package_dir( + package_dir, + "1.2.3", + PACKAGE_VARIANTS["codex-app-server"], + TARGET_SPECS["x86_64-unknown-linux-musl"], + inputs, + ) + validate_package_dir( + package_dir, + PACKAGE_VARIANTS["codex-app-server"], + TARGET_SPECS["x86_64-unknown-linux-musl"], + include_zsh=False, + ) + + self.assertTrue((package_dir / "bin" / "codex-code-mode-host").is_file()) + + +def touch_executable(path: Path) -> Path: + path.touch(mode=0o755) + return path + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index 986f6c6566..e27d34b223 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -538,6 +538,7 @@ function Test-PackageContentsAreComplete { $expectedFiles = @( "codex-package.json", "bin\codex.exe", + "bin\codex-code-mode-host.exe", "codex-path\rg.exe", "codex-resources\codex-command-runner.exe", "codex-resources\codex-windows-sandbox-setup.exe" diff --git a/scripts/install/install.sh b/scripts/install/install.sh index d44b353860..12b66f958a 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -680,7 +680,10 @@ install_package_release() { rm -rf "$stage_release" mkdir -p "$stage_release" tar -xzf "$archive_path" -C "$stage_release" - chmod 0755 "$stage_release/bin/codex" "$stage_release/codex-path/rg" + chmod 0755 \ + "$stage_release/bin/codex" \ + "$stage_release/bin/codex-code-mode-host" \ + "$stage_release/codex-path/rg" if [ -f "$stage_release/codex-resources/bwrap" ]; then chmod 0755 "$stage_release/codex-resources/bwrap" fi @@ -733,6 +736,7 @@ release_dir_is_complete() { package) [ -f "$release_dir/codex-package.json" ] && [ -x "$release_dir/bin/codex" ] && + [ -x "$release_dir/bin/codex-code-mode-host" ] && [ -x "$release_dir/codex" ] && [ -x "$release_dir/codex-path/rg" ] || return 1 diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index f3b97c6b6f..1c6ec59402 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -53,6 +53,10 @@ def runtime_binary_name() -> str: return "codex.exe" if _is_windows() else "codex" +def runtime_code_mode_host_name() -> str: + return "codex-code-mode-host.exe" if _is_windows() else "codex-code-mode-host" + + def staged_runtime_package_root(root: Path) -> Path: return root / RUNTIME_PACKAGE_ROOT @@ -276,6 +280,9 @@ def _validate_codex_package_layout(package_dir: Path, package_archive: Path) -> package_binary = package_dir / "bin" / runtime_binary_name() if not package_binary.is_file(): missing_entries.append(str(Path("bin") / runtime_binary_name())) + code_mode_host = package_dir / "bin" / runtime_code_mode_host_name() + if not code_mode_host.is_file(): + missing_entries.append(str(Path("bin") / runtime_code_mode_host_name())) if missing_entries: missing = ", ".join(missing_entries) raise RuntimeError(f"Missing Codex package layout entries in {package_archive}: {missing}") diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index 301a75abac..ad1262e836 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -57,6 +57,7 @@ def _write_fake_codex_package(package_dir: Path, script) -> Path: (package_dir / "codex-path").mkdir() (package_dir / "codex-package.json").write_text('{"variant":"codex"}\n') (package_dir / "bin" / script.runtime_binary_name()).write_text("fake codex\n") + (package_dir / "bin" / script.runtime_code_mode_host_name()).write_text("fake code mode host\n") (package_dir / "codex-resources" / "bwrap").write_text("fake bwrap\n") (package_dir / "codex-path" / "rg").write_text("fake rg\n") return package_dir @@ -682,11 +683,13 @@ def test_stage_runtime_release_copies_package_layout_and_sets_version( assert { "metadata": (package_root / "codex-package.json").read_text(), "codex": (package_root / "bin" / script.runtime_binary_name()).read_text(), + "code_mode_host": (package_root / "bin" / script.runtime_code_mode_host_name()).read_text(), "bwrap": (package_root / "codex-resources" / "bwrap").read_text(), "rg": (package_root / "codex-path" / "rg").read_text(), } == { "metadata": '{"variant":"codex"}\n', "codex": "fake codex\n", + "code_mode_host": "fake code mode host\n", "bwrap": "fake bwrap\n", "rg": "fake rg\n", } From c71895f63b44f439e10a0f09dc5dadba3e976de9 Mon Sep 17 00:00:00 2001 From: Eric Ning Date: Mon, 6 Jul 2026 17:12:39 -0700 Subject: [PATCH 2/9] [codex] app-server: expose plugin install policy source (#31293) ## Summary - Add `PluginInstallPolicySource` to the app-server v2 `PluginSummary` payload. - Propagate remote `installation_policy_source` through plugin summaries, details, installed-plugin caching, and app-server responses. - Preserve the supported source values and map unrecognized backend values to `null`. - Return `null` for local plugins and expose the field in generated JSON and TypeScript schemas. - Update app-server API documentation and plugin list/read coverage. ## Testing - `just test -p codex-app-server-protocol` (251 passed) - Targeted `codex-app-server` plugin list, installed, and read tests (4 passed) - Targeted `codex-core-plugins` known and unknown policy source tests (2 passed) - `just fmt` - `git diff --check` --- .../codex_app_server_protocol.schemas.json | 17 +++++ .../codex_app_server_protocol.v2.schemas.json | 17 +++++ .../json/v2/PluginInstalledResponse.json | 17 +++++ .../schema/json/v2/PluginListResponse.json | 17 +++++ .../schema/json/v2/PluginReadResponse.json | 17 +++++ .../json/v2/PluginShareListResponse.json | 17 +++++ .../v2/PluginInstallPolicySource.ts | 5 ++ .../schema/typescript/v2/PluginSummary.ts | 3 +- .../schema/typescript/v2/index.ts | 1 + .../src/protocol/v2/plugin.rs | 12 ++++ .../src/protocol/v2/tests.rs | 2 + codex-rs/app-server/README.md | 4 +- .../src/request_processors/plugins.rs | 3 + .../app-server/tests/suite/v2/plugin_list.rs | 33 +++++++++- .../app-server/tests/suite/v2/plugin_read.rs | 6 ++ .../app-server/tests/suite/v2/plugin_share.rs | 3 + codex-rs/core-plugins/src/manager_tests.rs | 1 + codex-rs/core-plugins/src/remote.rs | 31 +++++++++ .../core-plugins/src/remote/share/tests.rs | 2 + codex-rs/core-plugins/src/remote_tests.rs | 64 +++++++++++++++++++ codex-rs/tui/src/app/plugin_mentions.rs | 1 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 2 + 22 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallPolicySource.ts 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 0eedef903a..2ffff2f96f 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 @@ -13423,6 +13423,13 @@ ], "type": "string" }, + "PluginInstallPolicySource": { + "enum": [ + "WORKSPACE_SETTING", + "IMPLICIT_CANONICAL_APP" + ], + "type": "string" + }, "PluginInstallResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14306,6 +14313,16 @@ "installPolicy": { "$ref": "#/definitions/v2/PluginInstallPolicy" }, + "installPolicySource": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginInstallPolicySource" + }, + { + "type": "null" + } + ] + }, "installed": { "type": "boolean" }, 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 b428f25258..dc6e2c9693 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 @@ -9827,6 +9827,13 @@ ], "type": "string" }, + "PluginInstallPolicySource": { + "enum": [ + "WORKSPACE_SETTING", + "IMPLICIT_CANONICAL_APP" + ], + "type": "string" + }, "PluginInstallResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -10710,6 +10717,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/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/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/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index c335f144ec..00d39b31e3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -294,6 +294,7 @@ export type { PluginDetail } from "./PluginDetail"; export type { PluginHookSummary } from "./PluginHookSummary"; export type { PluginInstallParams } from "./PluginInstallParams"; export type { PluginInstallPolicy } from "./PluginInstallPolicy"; +export type { PluginInstallPolicySource } from "./PluginInstallPolicySource"; export type { PluginInstallResponse } from "./PluginInstallResponse"; export type { PluginInstalledParams } from "./PluginInstalledParams"; export type { PluginInstalledResponse } from "./PluginInstalledResponse"; 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/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/README.md b/codex-rs/app-server/README.md index a7986753ee..8300c70d94 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. diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 35c12d49c1..f0d7558526 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), @@ -2180,6 +2182,7 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS installed: summary.installed, enabled: summary.enabled, install_policy: summary.install_policy, + install_policy_source: summary.install_policy_source, auth_policy: summary.auth_policy, availability: summary.availability, interface: summary.interface, 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..0e4bb4a16a 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; @@ -196,6 +197,12 @@ enabled = true ] ); assert_eq!(response.marketplace_load_errors, Vec::new()); + assert!( + response.marketplaces[0] + .plugins + .iter() + .all(|plugin| plugin.install_policy_source.is_none()) + ); Ok(()) } @@ -289,11 +296,17 @@ enabled = true remote_marketplace .plugins .iter() - .map(|plugin| plugin.id.clone()) + .map(|plugin| (plugin.id.clone(), plugin.install_policy_source)) .collect::>(), vec![ - "linear@openai-curated-remote".to_string(), - "remote-only@openai-curated-remote".to_string(), + ( + "linear@openai-curated-remote".to_string(), + Some(PluginInstallPolicySource::WorkspaceSetting), + ), + ( + "remote-only@openai-curated-remote".to_string(), + Some(PluginInstallPolicySource::WorkspaceSetting), + ), ] ); assert_eq!(response.marketplace_load_errors, Vec::new()); @@ -462,6 +475,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ installed: false, enabled: false, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, interface: None, @@ -754,6 +768,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab installed: false, enabled: false, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(codex_app_server_protocol::PluginInterface { @@ -794,6 +809,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab installed: false, enabled: false, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, interface: None, @@ -1683,6 +1699,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", + "installation_policy_source": "IMPLICIT_CANONICAL_APP", "authentication_policy": "ON_USE", "status": "ENABLED", "release": { @@ -1722,6 +1739,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", + "installation_policy_source": "WORKSPACE_SETTING", "authentication_policy": "ON_USE", "status": "ENABLED", "release": { @@ -1845,6 +1863,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - ); assert_eq!(remote_marketplace.plugins[0].installed, true); assert_eq!(remote_marketplace.plugins[0].enabled, true); + assert_eq!( + remote_marketplace.plugins[0].install_policy_source, + Some(PluginInstallPolicySource::ImplicitCanonicalApp) + ); assert_eq!( remote_marketplace.plugins[0].availability, codex_app_server_protocol::PluginAvailability::Available @@ -1880,6 +1902,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - let cached_catalog: serde_json::Value = serde_json::from_slice(&std::fs::read(&cache_files[0])?)?; assert_eq!(cached_catalog["schema_version"], serde_json::json!(1)); + assert_eq!( + cached_catalog["plugins"][0]["installation_policy_source"], + serde_json::json!("IMPLICIT_CANONICAL_APP") + ); assert_eq!( cached_catalog["plugins"][0]["release"]["interface"]["default_prompts"], serde_json::json!(["Create a Linear issue", "Review my Linear projects"]) @@ -3938,6 +3964,7 @@ fn remote_installed_plugin_body_with_optional_app_manifest( "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", + "installation_policy_source": "WORKSPACE_SETTING", "authentication_policy": "ON_USE", "release": {{ "version": "{release_version}", 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..e3e83957cd 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; @@ -144,6 +145,7 @@ apps = true "name": "example-plugin", "scope": "GLOBAL", "installation_policy": "AVAILABLE", + "installation_policy_source": "IMPLICIT_CANONICAL_APP", "authentication_policy": "ON_USE", "release": { "version": "1.2.1", @@ -267,6 +269,10 @@ apps = true assert_eq!(response.plugin.summary.name, "example-plugin"); assert_eq!(response.plugin.summary.source, PluginSource::Remote); assert_eq!(response.plugin.summary.share_context, None); + assert_eq!( + response.plugin.summary.install_policy_source, + Some(PluginInstallPolicySource::ImplicitCanonicalApp) + ); assert_eq!( response .plugin 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..3f1c2734f2 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -179,6 +179,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { installed: true, enabled: true, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), @@ -583,6 +584,7 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { installed: true, enabled: true, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), @@ -1184,6 +1186,7 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { installed: true, enabled: true, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), 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_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/tui/src/app/plugin_mentions.rs b/codex-rs/tui/src/app/plugin_mentions.rs index ae2d25a4dc..cae0caa8f0 100644 --- a/codex-rs/tui/src/app/plugin_mentions.rs +++ b/codex-rs/tui/src/app/plugin_mentions.rs @@ -173,6 +173,7 @@ mod tests { installed: true, enabled: true, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnInstall, availability: PluginAvailability::Available, interface: None, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 1f9374bbc3..5f890ab000 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1341,6 +1341,7 @@ pub(super) fn plugins_test_summary( installed, enabled, install_policy, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnInstall, availability: PluginAvailability::Available, interface: Some(plugins_test_interface( @@ -1370,6 +1371,7 @@ pub(super) fn plugins_test_remote_summary( installed, enabled: true, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnInstall, availability: PluginAvailability::Available, interface: Some(plugins_test_interface( From 7226904ed11eac2755a6641fa32af1d573355aff Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 6 Jul 2026 17:27:44 -0700 Subject: [PATCH 3/9] ci: share common workflow setup (#31318) ## Why CI jobs repeat common bootstrap steps, which makes it harder to keep Bazel and Cargo lanes aligned. Centralizing the lightweight setup gives us one place for future runner-wide optimizations without adding Rust toolchain or component installation to the Windows Bazel long poles. ## What - add zero-input `.github/actions/setup-ci` to set Cargo's git transport, install DotSlash and `just`, expose DotSlash from stable PATH locations, and enable Windows Git long paths - have `setup-bazel-ci` compose the common setup, then remove its DotSlash/test-prerequisite plumbing - migrate Bazel, Cargo CI, V8 canary, repo checks, nextest, and Windows release call sites while keeping Rust toolchain and MSVC setup explicit - preserve the existing nextest Dev Drive setup unchanged ## Validation - `just test-github-scripts` (30 tests) - parsed workflow and composite-action YAML with `yq` --- .github/actions/prepare-bazel-ci/action.yml | 5 --- .../run-argument-comment-lint/action.yml | 1 - .github/actions/setup-bazel-ci/action.yml | 27 +------------ .github/actions/setup-ci/action.yml | 39 +++++++++++++++++++ .github/scripts/test_v8_canary_changes.py | 9 +++++ .github/scripts/v8_canary_changes.py | 15 ++++++- .github/workflows/bazel.yml | 17 +++----- .github/workflows/cargo-deny.yml | 7 +--- .github/workflows/repo-checks.yml | 7 +--- .../rust-ci-full-nextest-platform.yml | 15 ++----- .github/workflows/rust-ci-full.yml | 15 ++----- .github/workflows/rust-ci.yml | 11 ++---- .github/workflows/rust-release-windows.yml | 10 ++--- .github/workflows/v8-canary.yml | 7 +--- 14 files changed, 89 insertions(+), 96 deletions(-) create mode 100644 .github/actions/setup-ci/action.yml 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..0924e29616 --- /dev/null +++ b/.github/actions/setup-ci/action.yml @@ -0,0 +1,39 @@ +name: setup-ci +description: Prepare common tools and environment shared by CI jobs. + +runs: + using: composite + steps: + - name: Prefer the Git CLI for Cargo git dependencies + shell: bash + run: echo "CARGO_NET_GIT_FETCH_WITH_CLI=true" >> "$GITHUB_ENV" + + - name: Install DotSlash + uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + + - name: Install just + uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + with: + tool: just@1.51.0 + + # Some integration tests spawn DotSlash from a stable system path rather + # than inheriting the action-local PATH entry. + - name: Make DotSlash available in PATH (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + if [[ -w /usr/local/bin ]]; then + cp "$(which dotslash)" /usr/local/bin + else + sudo cp "$(which dotslash)" /usr/local/bin + fi + + - name: Make DotSlash available in PATH (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: Copy-Item (Get-Command dotslash).Source -Destination "$env:LOCALAPPDATA\Microsoft\WindowsApps\dotslash.exe" + + - name: Enable Git long paths (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: git config --global core.longpaths true 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..825c2d73d6 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 }} @@ -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 }} diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index cb99c8911e..dc38aaa86d 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 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 31d832fcbe..ccb2ee13f7 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -3,10 +3,7 @@ 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-code-mode-host codex-responses-api-proxy codex-windows-sandbox-setup codex-command-runner codex-app-server" jobs: @@ -72,6 +69,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - uses: ./.github/actions/setup-ci - name: Print runner specs (Windows) shell: powershell run: | @@ -164,6 +162,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - uses: ./.github/actions/setup-ci - name: Download prebuilt Windows binaries uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -221,6 +220,8 @@ jobs: with: persist-credentials: false + - uses: ./.github/actions/setup-ci + - name: Download prebuilt Windows primary binaries uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -270,9 +271,6 @@ jobs: "$dest/${binary}-${{ matrix.target }}.exe" done - - name: Install DotSlash - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - - name: Build Codex package archives shell: bash run: | diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 53d9585108..f338c341e4 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' }} @@ -325,6 +320,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: ./.github/actions/setup-ci + - name: Configure git for upstream checkout shell: bash run: git config --global core.symlinks true From 8a18312ee58b6dce10edca44bbed90365f384edb Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 6 Jul 2026 17:42:43 -0700 Subject: [PATCH 4/9] app-server: cover selected environments in integration tests (#29992) ## Why Now that basic cross-OS app/exec support is wired up, it's time to clean up the tech debt of the remote_env_windows test and make sure its test logic is covered in more maintainable feature-specific tests. ## What - Add focused app-server tests for target-native `AGENTS.md` sources and content, plus shell and cwd context, while preserving explicit TODO baselines for the remaining host-scoped metadata. - Add a `TestAppServer` helper that waits for and returns the matching typed turn completion. - Remove redundant app-server coverage and dependencies from `remote_env_windows` while retaining its exec and apply-patch smoke coverage. A follow-up will remove these. ## Validation - `just test -p codex-app-server` - `bazel test //codex-rs/app-server:app-server-all-wine-exec-test --test_output=errors` - `bazel test //codex-rs/core/tests/remote_env_windows:smoke-test --test_output=errors` --- .../tests/common/test_app_server.rs | 50 +++- codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/selected_environment.rs | 252 ++++++++++++++++++ .../core/tests/remote_env_windows/BUILD.bazel | 6 - .../remote_env_windows_test.rs | 171 ------------ 5 files changed, 298 insertions(+), 182 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/selected_environment.rs 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..0fc0594baa 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -111,6 +111,7 @@ use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnEnvironmentParams; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::WindowsSandboxSetupStartParams; use codex_exec_server::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR; @@ -216,15 +217,21 @@ impl TestAppServer { Ok(app_server) } + /// Returns the automatically selected test environment retained by this server. + /// + /// Tests can use the environment to arrange target-native filesystem fixtures before starting + /// a thread. Returns an error unless this server was created with [`Self::new_with_auto_env`]. + pub fn auto_env(&self) -> anyhow::Result<&TestEnv> { + self.auto_env + .as_ref() + .context("auto environment is unavailable; use TestAppServer::new_with_auto_env") + } + /// Returns app-server protocol parameters for the automatically selected /// test environment. Returns an error unless this server was created with /// [`Self::new_with_auto_env`]. pub fn auto_env_params(&self) -> anyhow::Result { - let selection = self - .auto_env - .as_ref() - .context("auto environment is unavailable; use TestAppServer::new_with_auto_env")? - .selection(); + let selection = self.auto_env()?.selection(); Ok(TurnEnvironmentParams { environment_id: selection.environment_id.clone(), cwd: selection.cwd.clone().into(), @@ -1052,6 +1059,39 @@ impl TestAppServer { self.send_request("turn/start", params).await } + /// Start a turn and return its matching typed completion notification. + pub async fn start_turn_and_wait_for_completion( + &mut self, + params: TurnStartParams, + ) -> anyhow::Result { + 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, 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/selected_environment.rs b/codex-rs/app-server/tests/suite/v2/selected_environment.rs new file mode 100644 index 0000000000..513cd25d7f --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/selected_environment.rs @@ -0,0 +1,252 @@ +use std::collections::BTreeMap; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use app_test_support::PathBufExt; +use app_test_support::TestAppServer; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::UserInput as V2UserInput; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const AGENTS_INSTRUCTIONS: &str = "selected environment workspace instructions"; +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); + +fn text_turn_params(thread_id: String, prompt: &str) -> TurnStartParams { + TurnStartParams { + thread_id, + input: vec![V2UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + } +} + +#[tokio::test] +async fn thread_start_reports_selected_environment_metadata() -> Result<()> { + let server = responses::start_mock_server().await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 100_000, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + let mut app_server = TestAppServer::new_with_auto_env(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + + let request_id = app_server + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + cwd, + runtime_workspace_roots, + active_permission_profile, + .. + } = to_response(response)?; + let host_cwd = codex_home.path().to_path_buf().abs().canonicalize()?; + let cwd = cwd.canonicalize()?; + let runtime_workspace_roots = runtime_workspace_roots + .into_iter() + .map(|root| root.canonicalize()) + .collect::>>()?; + assert_eq!( + (cwd, runtime_workspace_roots, active_permission_profile), + ( + // TODO(anp): Return the selected environment's native cwd from thread/start. + host_cwd.clone(), + // TODO(anp): Derive runtime workspace roots from the selected remote environment. + vec![host_cwd], + // TODO(anp): Report the implicit built-in permission profile instead of None. + None, + ) + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_reports_selected_environment_instruction_source() -> Result<()> { + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "done"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 100_000, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + let mut app_server = TestAppServer::new_with_auto_env(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + + let (agents_source, environment_cwd) = { + let auto_env = app_server.auto_env()?; + let environment_cwd = auto_env.selection().cwd.clone(); + let agents_source = environment_cwd.join("AGENTS.md")?; + auto_env + .environment() + .get_filesystem() + .write_file( + &agents_source, + AGENTS_INSTRUCTIONS.as_bytes().to_vec(), + /*sandbox*/ None, + ) + .await?; + (agents_source, environment_cwd) + }; + + let request_id = app_server + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadStartResponse = to_response(response)?; + + assert_eq!(response.instruction_sources, vec![agents_source.into()]); + timeout( + DEFAULT_READ_TIMEOUT, + app_server.start_turn_and_wait_for_completion(text_turn_params( + response.thread.id, + "inspect workspace instructions", + )), + ) + .await??; + + let user_context = response_mock.single_request().message_input_texts("user"); + let instructions = user_context + .iter() + .find(|text| text.starts_with("# AGENTS.md instructions")) + .context("selected environment instructions should be model visible")?; + let expected_instructions = format!( + "# AGENTS.md instructions for {}\n\n\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::new_with_auto_env(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + let (environment_cwd, environment_shell) = { + let auto_env = app_server.auto_env()?; + ( + auto_env.selection().cwd.clone(), + auto_env.environment().info().await?.shell.name, + ) + }; + + let request_id = app_server + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + thread, + runtime_workspace_roots, + .. + } = to_response(response)?; + timeout( + DEFAULT_READ_TIMEOUT, + app_server.start_turn_and_wait_for_completion(text_turn_params( + thread.id, + "inspect the selected environment", + )), + ) + .await??; + + let user_context = response_mock.single_request().message_input_texts("user"); + let environment_context = user_context + .iter() + .find(|text| text.starts_with("")) + .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/core/tests/remote_env_windows/BUILD.bazel b/codex-rs/core/tests/remote_env_windows/BUILD.bazel index 9b5ce52bd9..1f2465ffcb 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", }, 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 -} From 45be435135a7936f4b01916a6d8f0c8091229fb8 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 6 Jul 2026 18:08:35 -0700 Subject: [PATCH 5/9] Warn when configured service tiers are unsupported (#31284) ## Why Codex currently omits a configured `service_tier` when the selected model's catalog entry does not advertise support for it. That fallback is silent, so users can unknowingly send requests at the default tier instead. This makes cases such as #26604 difficult to diagnose. ## What changed - Emit the shared core `Warning` event during session startup when a configured service tier will be omitted because the initial model does not advertise support for it. - Do not warn on later model or service-tier changes, which keeps warning emission stateless and avoids client-specific handling. - Keep the existing request filtering behavior unchanged. ## Validation - `just test -p codex-core unsupported_service_tier` - `just test -p codex-core unsupported_configured_service_tier_warns_at_session_start` ### Manual validation - Launched the real TUI with the bundled catalog, where `gpt-5.5` advertises the `priority` tier but not `flex`, and configured `service_tier = "flex"`. - Confirmed the unsupported-tier warning appeared exactly once during startup. - Submitted two turns through a local Responses API SSE stub; both received mock replies and neither emitted another warning. - Inspected both captured `/v1/responses` request bodies and confirmed that neither contained a `service_tier` field. - Repeated the two-turn TUI flow against the live Responses API; both turns completed and the warning was not repeated. --- codex-rs/core/src/session/mod.rs | 33 ++++++++++++++-- codex-rs/core/tests/suite/model_switching.rs | 40 ++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index ba8eacb395..91a21ef731 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -629,11 +629,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, @@ -706,6 +709,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. @@ -908,6 +919,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 { 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(())); From 9acfe8965d44af60c71227f4577902e467ba5119 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 6 Jul 2026 18:12:24 -0700 Subject: [PATCH 6/9] Extract shared HTTP transport into codex-http-client (#31323) ## Why Codex-owned HTTP construction currently lives in `codex-client` alongside higher-level retry, SSE, and request-telemetry policy. That makes it difficult to apply shared network behavior consistently across crates, particularly system proxy/PAC resolution, custom CA handling, and the ChatGPT Cloudflare cookie policy. It also leaves no clear crate boundary for migrating direct `reqwest` usage behind a single Codex abstraction. This change establishes that low-level ownership boundary without changing request behavior. It builds on the system proxy support introduced in #26706, #26707, #26708, and #26709. ## What changed - Added `codex-rs/http-client` as the `codex-http-client` crate. - Moved request/response types, the concrete `reqwest` transport, custom CA handling, Cloudflare cookie policy, and macOS/Windows proxy resolution into the new crate. - Kept retry, SSE, and request-telemetry policy in `codex-client`. - Re-exported the moved API from `codex-client`, including compatibility aliases for `CodexHttpClient` and `CodexRequestBuilder`, so existing consumers do not change in this PR. - Moved the existing proxy and custom-CA tests with their implementation. ## Scope boundary This PR deliberately stops at the crate extraction. Stacked follow-up #31331 migrates downstream imports from `codex-client` to `codex-http-client`, keeping this change focused on ownership and compatibility rather than mixing in repository-wide call-site churn. ## Review guide GitHub reports 30 changed files, of which 17 are detected renames. A useful review order is: 1. Review the new boundary in `codex-rs/http-client/Cargo.toml` and `codex-rs/http-client/src/lib.rs`. 2. Review `codex-rs/codex-client/Cargo.toml` and `codex-rs/codex-client/src/lib.rs` for what remains in the higher-level crate and how compatibility is preserved. 3. Treat the renamed implementation and test files as moves. Their meaningful edits are limited to crate paths and normalizing the new crate's type names to `HttpClient` and `RequestBuilder`. 4. Review `codex-rs/Cargo.toml`, `codex-rs/Cargo.lock`, and the two `BUILD.bazel` files as mechanical workspace integration. ## Test plan - `just test -p codex-http-client -p codex-client` (38 tests) - Compile-checked the unchanged `codex-api`, `codex-backend-client`, `codex-cloud-tasks`, `codex-exec-server`, `codex-login`, and `codex-model-provider` consumers against the compatibility re-exports. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/31323). * #31331 * __->__ #31323 --- codex-rs/Cargo.lock | 54 +++++++++++-------- codex-rs/Cargo.toml | 2 + codex-rs/codex-client/BUILD.bazel | 1 - codex-rs/codex-client/Cargo.toml | 35 +----------- codex-rs/codex-client/README.md | 6 +-- codex-rs/codex-client/src/lib.rs | 42 ++------------- codex-rs/codex-client/src/retry.rs | 4 +- codex-rs/codex-client/src/sse.rs | 4 +- codex-rs/codex-client/src/telemetry.rs | 2 +- codex-rs/http-client/BUILD.bazel | 7 +++ codex-rs/http-client/Cargo.toml | 49 +++++++++++++++++ codex-rs/http-client/README.md | 9 ++++ .../src/bin/custom_ca_probe.rs | 8 +-- .../src/chatgpt_cloudflare_cookies.rs | 2 +- .../src/chatgpt_hosts.rs | 2 +- .../src/custom_ca.rs | 2 +- .../src/default_client.rs | 16 +++--- .../src/error.rs | 2 + codex-rs/http-client/src/lib.rs | 40 ++++++++++++++ .../src/outbound_proxy.rs | 2 +- .../src/outbound_proxy/macos.rs | 2 + .../src/outbound_proxy/windows.rs | 2 + .../src/outbound_proxy/windows_tests.rs | 2 + .../src/outbound_proxy_tests.rs | 2 + .../src/request.rs | 6 +-- .../src/transport.rs | 10 ++-- .../tests/ca_env.rs | 2 +- .../tests/fixtures/test-ca-trusted.pem | 0 .../tests/fixtures/test-ca.pem | 0 .../tests/fixtures/test-intermediate.pem | 0 30 files changed, 187 insertions(+), 128 deletions(-) create mode 100644 codex-rs/http-client/BUILD.bazel create mode 100644 codex-rs/http-client/Cargo.toml create mode 100644 codex-rs/http-client/README.md rename codex-rs/{codex-client => http-client}/src/bin/custom_ca_probe.rs (91%) rename codex-rs/{codex-client => http-client}/src/chatgpt_cloudflare_cookies.rs (98%) rename codex-rs/{codex-client => http-client}/src/chatgpt_hosts.rs (96%) rename codex-rs/{codex-client => http-client}/src/custom_ca.rs (99%) rename codex-rs/{codex-client => http-client}/src/default_client.rs (93%) rename codex-rs/{codex-client => http-client}/src/error.rs (91%) create mode 100644 codex-rs/http-client/src/lib.rs rename codex-rs/{codex-client => http-client}/src/outbound_proxy.rs (99%) rename codex-rs/{codex-client => http-client}/src/outbound_proxy/macos.rs (99%) rename codex-rs/{codex-client => http-client}/src/outbound_proxy/windows.rs (99%) rename codex-rs/{codex-client => http-client}/src/outbound_proxy/windows_tests.rs (93%) rename codex-rs/{codex-client => http-client}/src/outbound_proxy_tests.rs (99%) rename codex-rs/{codex-client => http-client}/src/request.rs (98%) rename codex-rs/{codex-client => http-client}/src/transport.rs (94%) rename codex-rs/{codex-client => http-client}/tests/ca_env.rs (99%) rename codex-rs/{codex-client => http-client}/tests/fixtures/test-ca-trusted.pem (100%) rename codex-rs/{codex-client => http-client}/tests/fixtures/test-ca.pem (100%) rename codex-rs/{codex-client => http-client}/tests/fixtures/test-intermediate.pem (100%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c0ff4a585a..c6eeb7f978 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2392,33 +2392,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]] @@ -3214,6 +3193,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" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 2f73c216a9..ad08ce8e02 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", @@ -164,6 +165,7 @@ codex-cloud-tasks-mock-client = { path = "cloud-tasks-mock-client" } codex-code-mode = { path = "code-mode" } codex-code-mode-protocol = { path = "code-mode-protocol" } codex-home = { path = "codex-home" } +codex-http-client = { path = "http-client" } codex-config = { path = "config" } codex-connectors = { path = "connectors" } codex-connectors-extension = { path = "ext/connectors" } 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/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 99% rename from codex-rs/codex-client/src/custom_ca.rs rename to codex-rs/http-client/src/custom_ca.rs index 1a211beedf..4cfae8f23d 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. 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..5f90435452 --- /dev/null +++ b/codex-rs/http-client/src/lib.rs @@ -0,0 +1,40 @@ +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::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::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::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 99% rename from codex-rs/codex-client/src/outbound_proxy.rs rename to codex-rs/http-client/src/outbound_proxy.rs index ea6587052e..e643698a8c 100644 --- a/codex-rs/codex-client/src/outbound_proxy.rs +++ b/codex-rs/http-client/src/outbound_proxy.rs @@ -1,4 +1,4 @@ -//! 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. 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 99% rename from codex-rs/codex-client/src/outbound_proxy_tests.rs rename to codex-rs/http-client/src/outbound_proxy_tests.rs index 46868e8f73..60a1d56396 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; 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 From 641aa1b61902753c2da02443a13bfdd3d039c568 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 6 Jul 2026 18:34:36 -0700 Subject: [PATCH 7/9] Migrate direct HTTP consumers to codex-http-client (#31331) ## Why #31323 introduces `codex-http-client` and leaves compatibility re-exports in `codex-client`. Low-level HTTP consumers should depend on the crate that now owns those APIs rather than continuing through the transitional compatibility layer. This stacked follow-up makes that ownership explicit and moves the repository toward enforcing the abstraction without mixing call-site churn into the extraction itself. ## What changed - Switched `codex-backend-client`, `codex-cloud-tasks`, `codex-exec-server`, `codex-login`, and `codex-model-provider` from `codex-client` to `codex-http-client` where they only use low-level HTTP APIs. - Added the direct dependency to `codex-api` for its custom-CA request and websocket paths while retaining `codex-client` for higher-level retry and transport policy. - Updated imports and normalized login's internal client type name from `CodexHttpClient` to `HttpClient`, while preserving its existing `CodexRequestBuilder` re-export. - Updated `Cargo.lock` to reflect the new direct dependency edges. ## Review guide This PR is intentionally mechanical: 20 files and 92 changed lines, with no runtime logic changes. The largest diff is `codex-rs/login/src/auth/default_client.rs`, where the only semantic-looking changes are type and import renames. The remaining source changes replace `codex_client` import paths with `codex_http_client`; the manifest and lockfile changes mirror those imports. ## Test plan - Compile-checked `codex-api`, `codex-backend-client`, `codex-cloud-tasks`, `codex-exec-server`, `codex-login`, and `codex-model-provider` together. --- codex-rs/Cargo.lock | 11 ++++---- codex-rs/backend-client/Cargo.toml | 2 +- codex-rs/backend-client/src/client.rs | 4 +-- codex-rs/cloud-tasks/Cargo.toml | 2 +- codex-rs/cloud-tasks/src/env_detect.rs | 2 +- codex-rs/codex-api/Cargo.toml | 1 + .../endpoint/realtime_websocket/methods.rs | 2 +- .../src/endpoint/responses_websocket.rs | 2 +- codex-rs/codex-api/src/files.rs | 2 +- codex-rs/exec-server/Cargo.toml | 2 +- .../src/client/reqwest_http_client.rs | 4 +-- codex-rs/login/Cargo.toml | 2 +- codex-rs/login/src/auth/default_client.rs | 26 +++++++++---------- codex-rs/login/src/auth/manager.rs | 8 +++--- .../login/src/auth/personal_access_token.rs | 4 +-- codex-rs/login/src/auth/revoke.rs | 6 ++--- codex-rs/login/src/lib.rs | 2 +- codex-rs/login/src/outbound_proxy.rs | 2 +- codex-rs/model-provider/Cargo.toml | 2 +- .../model-provider/src/amazon_bedrock/auth.rs | 6 ++--- 20 files changed, 47 insertions(+), 45 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c6eeb7f978..9d89f8eb1f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1936,6 +1936,7 @@ dependencies = [ "bytes", "chrono", "codex-client", + "codex-http-client", "codex-protocol", "codex-utils-rustls-provider", "eventsource-stream", @@ -2262,7 +2263,7 @@ dependencies = [ "anyhow", "codex-api", "codex-backend-openapi-models", - "codex-client", + "codex-http-client", "codex-login", "codex-model-provider", "codex-protocol", @@ -2431,11 +2432,11 @@ dependencies = [ "anyhow", "chrono", "clap", - "codex-client", "codex-cloud-tasks-client", "codex-cloud-tasks-mock-client", "codex-core", "codex-git-utils", + "codex-http-client", "codex-login", "codex-model-provider", "codex-tui", @@ -2895,9 +2896,9 @@ dependencies = [ "bytes", "clatter", "codex-api", - "codex-client", "codex-exec-server-protocol", "codex-file-system", + "codex-http-client", "codex-network-proxy", "codex-otel", "codex-protocol", @@ -3315,8 +3316,8 @@ dependencies = [ "base64 0.22.1", "chrono", "codex-agent-identity", - "codex-client", "codex-config", + "codex-http-client", "codex-keyring-store", "codex-model-provider-info", "codex-otel", @@ -3530,8 +3531,8 @@ dependencies = [ "codex-agent-identity", "codex-api", "codex-aws-auth", - "codex-client", "codex-feedback", + "codex-http-client", "codex-login", "codex-model-provider-info", "codex-models-manager", 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/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/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/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index 0ae443d00a..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; diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 7f261bba36..447450fae1 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; 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/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 35fa411021..ba9058f0c6 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 } 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/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/default_client.rs b/codex-rs/login/src/auth/default_client.rs index f72727bfca..1146a89d96 100644 --- a/codex-rs/login/src/auth/default_client.rs +++ b/codex-rs/login/src/auth/default_client.rs @@ -1,17 +1,17 @@ //! 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; +pub use codex_http_client::RequestBuilder as CodexRequestBuilder; +use codex_http_client::build_reqwest_client_for_route; +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 +197,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. @@ -282,8 +282,8 @@ 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) } pub fn default_headers() -> HeaderMap { diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 844410874d..a2d005dfa6 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -55,8 +55,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; @@ -171,7 +171,7 @@ pub struct ChatgptAuthTokens { #[derive(Debug, Clone)] struct ChatgptAuthState { auth_dot_json: Arc>>, - client: CodexHttpClient, + client: HttpClient, } const TOKEN_REFRESH_INTERVAL: i64 = 8; @@ -805,7 +805,7 @@ impl ChatgptAuth { &self.storage } - fn client(&self) -> &CodexHttpClient { + fn client(&self) -> &HttpClient { &self.state.client } @@ -1331,7 +1331,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(), 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..7ea415fcc5 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -7,8 +7,8 @@ mod outbound_proxy; mod pkce; mod server; -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; diff --git a/codex-rs/login/src/outbound_proxy.rs b/codex-rs/login/src/outbound_proxy.rs index bf7dfbeb4d..66441e3637 100644 --- a/codex-rs/login/src/outbound_proxy.rs +++ b/codex-rs/login/src/outbound_proxy.rs @@ -1,4 +1,4 @@ -use codex_client::OutboundProxyConfig; +use codex_http_client::OutboundProxyConfig; /// Auth-layer adapter around client-owned proxy policy. /// 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; From 8f3431054fe44dbf0d901bb12c522bb87c64fd47 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 6 Jul 2026 19:08:50 -0700 Subject: [PATCH 8/9] fix: restore Codex environment setup table (#31337) ## Why Creating a Codex worktree skips local environment setup because `.codex/environments/environment.toml` omits the required `setup` object. ## What Restore an empty `[setup]` table with `script = ""`, preserving the intended no-op setup while satisfying the environment parser schema. ## Validation - Parsed the final TOML and verified `setup.script` is present as a string. --- .codex/environments/environment.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index b3c5910fbc..ae0bd24ca8 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -2,6 +2,10 @@ version = 1 name = "codex" +# TODO(anp) make it optional to specify this field +[setup] +script = "" + [[actions]] name = "Run" icon = "run" From 831c14fc39f7810703263c31a4a712842dba35f4 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 6 Jul 2026 19:28:44 -0700 Subject: [PATCH 9/9] Preserve managed exec policy after rules parse errors (#31188) ## Why Cleanup for #31179 exposed a core fallback bug that the TUI had been handling locally. When a custom `.rules` file fails to parse, nonfatal clients warn and continue, but `load_exec_policy_with_warning` replaced the entire policy with `Policy::empty()` before managed requirements were merged. App-server and desktop clients could therefore silently lose required prompt and forbidden rules. This fix is intentionally separate from #31179 so the TUI cleanup does not depend on it. ## What changed - Preserve the managed requirements exec policy when custom file rules fail to parse, while returning the existing warning and discarding the file-based policy. - Use the same nonfatal fallback when loading network proxy policy. - Keep parse errors fatal for strict clients such as `codex exec`. --- codex-rs/core/src/exec_policy.rs | 11 ++- codex-rs/core/src/exec_policy_tests.rs | 36 +++++++++ codex-rs/core/src/network_proxy_loader.rs | 27 +++---- .../core/src/network_proxy_loader_tests.rs | 50 ++++++++++++ codex-rs/core/tests/suite/exec_policy.rs | 80 +++++++++++++++++++ 5 files changed, 188 insertions(+), 16 deletions(-) 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/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/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;