From ebb79803697acee75baf24073ef49af87ad7e483 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 2 Jun 2026 09:56:20 -0700 Subject: [PATCH 01/19] Route Bazel CI through shared BuildBuddy remote config wrapper (#25156) ## Why Bazel remote configuration was selected in several CI scripts and workflow steps. That made the BuildBuddy tenant policy easy to duplicate and harder to audit, especially for fork pull requests that must not use the OpenAI tenant. This builds on [sluongng/buildbuddy-ci-host-routing](https://github.com/openai/codex/compare/main...sluongng:codex:sluongng/buildbuddy-ci-host-routing) and consolidates the policy in one place. ## What to do if this breaks you See `codex-rs/docs/bazel.md` for details. TLDR: 1. make a BuildBuddy API key and put it in `~/.bazelrc` 2. if you're an OpenAI employee, add `common --config=buildbuddy-openai-rbe` to `user.bazelrc` in the repo root Run `just bazel-test` to ensure it works. Note that `just bazel-remote-test` no longer exists, you need to select a remote configuration as documented to use RBE. ## What changed - Add `.github/scripts/run_bazel_with_buildbuddy.py` as the shared Bazel wrapper and Python library. It selects the OpenAI host only for trusted upstream GitHub Actions runs, routes keyed fork runs to the generic host, and falls back to local Bazel execution when no key is available. - Move endpoint selection into explicit `.bazelrc` configurations and update Bazel CI, query helpers, and `rusty_v8` staging to use the shared policy. Loading-phase target-discovery queries remain local. - Add wrapper and `rusty_v8` unit coverage, plus `just test-scripts` for the `.github/scripts` Python tests. - Document local Bazel usage, `user.bazelrc` setup, BuildBuddy configurations, and CI behavior in `codex-rs/docs/bazel.md`. ## Validation - `just test-scripts` - `bash -n .github/scripts/run-bazel-ci.sh .github/scripts/run-bazel-query-ci.sh .github/scripts/run-argument-comment-lint-bazel.sh scripts/list-bazel-clippy-targets.sh` - `python3 -m py_compile .github/scripts/run_bazel_with_buildbuddy.py .github/scripts/test_run_bazel_with_buildbuddy.py .github/scripts/test_rusty_v8_bazel.py .github/scripts/rusty_v8_bazel.py` - `ruff check .github/scripts/run_bazel_with_buildbuddy.py .github/scripts/test_run_bazel_with_buildbuddy.py .github/scripts/test_rusty_v8_bazel.py .github/scripts/rusty_v8_bazel.py` --- .bazelrc | 46 +++-- .github/scripts/run-bazel-ci.sh | 126 +++++------- .github/scripts/run-bazel-query-ci.sh | 56 ++---- .github/scripts/run_bazel_with_buildbuddy.py | 142 ++++++++++++++ .github/scripts/rusty_v8_bazel.py | 63 +++--- .../scripts/test_run_bazel_with_buildbuddy.py | 184 ++++++++++++++++++ .github/scripts/test_rusty_v8_bazel.py | 49 +++-- .github/workflows/bazel.yml | 8 +- .github/workflows/rusty-v8-release.yml | 5 +- .github/workflows/v8-canary.yml | 7 +- codex-rs/docs/bazel.md | 114 ++++++++++- justfile | 11 +- scripts/list-bazel-clippy-targets.sh | 24 +-- 13 files changed, 617 insertions(+), 218 deletions(-) create mode 100755 .github/scripts/run_bazel_with_buildbuddy.py create mode 100644 .github/scripts/test_run_bazel_with_buildbuddy.py diff --git a/.bazelrc b/.bazelrc index 6357eb83e4..e39a3aff22 100644 --- a/.bazelrc +++ b/.bazelrc @@ -38,24 +38,50 @@ common:windows --test_env=WINDIR common --test_env=RUST_MIN_STACK=8388608 # 8 MiB common --test_output=errors -common --bes_results_url=https://app.buildbuddy.io/invocation/ -common --bes_backend=grpcs://remote.buildbuddy.io -common --remote_cache=grpcs://remote.buildbuddy.io -common --remote_download_toplevel common --nobuild_runfile_links +# These settings tune BuildBuddy/RBE behavior but do not contact a remote +# service unless a `buildbuddy-*` configuration below supplies an endpoint. +common --remote_download_toplevel common --remote_timeout=3600 common --noexperimental_throttle_remote_action_building common --experimental_remote_execution_keepalive common --grpc_keepalive_time=30s -common --experimental_remote_downloader=grpcs://remote.buildbuddy.io + +# Opt-in remote configurations selected by +# `.github/scripts/run_bazel_with_buildbuddy.py`. Plain Bazel commands do not +# contact BuildBuddy unless a user selects one of these configurations. +# Use the generic host for cache, BES, and downloads without remote execution. +common:buildbuddy-generic --bes_backend=grpcs://remote.buildbuddy.io +common:buildbuddy-generic --bes_results_url=https://app.buildbuddy.io/invocation/ +common:buildbuddy-generic --remote_cache=grpcs://remote.buildbuddy.io +common:buildbuddy-generic --experimental_remote_downloader=grpcs://remote.buildbuddy.io + +# Add remote execution on the generic host. +common:buildbuddy-generic-rbe --config=buildbuddy-generic +common:buildbuddy-generic-rbe --config=remote +common:buildbuddy-generic-rbe --remote_executor=grpcs://remote.buildbuddy.io + +# Use the OpenAI tenant for cache, BES, and downloads without remote execution. +common:buildbuddy-openai --bes_backend=grpcs://openai.buildbuddy.io +common:buildbuddy-openai --bes_results_url=https://openai.buildbuddy.io/invocation/ +common:buildbuddy-openai --remote_cache=grpcs://openai.buildbuddy.io +common:buildbuddy-openai --experimental_remote_downloader=grpcs://openai.buildbuddy.io + +# Add remote execution on the OpenAI tenant. +common:buildbuddy-openai-rbe --config=buildbuddy-openai +common:buildbuddy-openai-rbe --config=remote +common:buildbuddy-openai-rbe --remote_executor=grpcs://openai.buildbuddy.io # This limits both in-flight executions and concurrent downloads. Even with high number # of jobs execution will still be limited by CPU cores, so this just pays a bit of # memory in exchange for higher download concurrency. common --jobs=30 +# Shared remote execution policy. The endpoint-bearing `buildbuddy-*-rbe` +# configurations include this group; CI configs override TestRunner below +# when tests must remain local on their runner. +common:remote --strategy=remote common:remote --extra_execution_platforms=//:rbe -common:remote --remote_executor=grpcs://remote.buildbuddy.io common:remote --jobs=800 # TODO(team): Evaluate if this actually helps, zbarsky is not sure, everything seems bottlenecked on `core` either way. # Enable pipelined compilation since we are not bound by local CPU count. @@ -146,15 +172,11 @@ common:ci-windows --repo_contents_cache=D:/a/.cache/bazel-repo-contents-cache # Linux crossbuilds don't work until we untangle the libc constraint mess. common:ci-linux --config=ci-bazel common:ci-linux --build_metadata=TAG_os=linux -common:ci-linux --config=remote -common:ci-linux --strategy=remote common:ci-linux --platforms=//:rbe # On mac, we can run all the build actions remotely but test actions locally. common:ci-macos --config=ci-bazel common:ci-macos --build_metadata=TAG_os=macos -common:ci-macos --config=remote -common:ci-macos --strategy=remote common:ci-macos --strategy=TestRunner=darwin-sandbox,local # On Windows, use Linux remote execution for build actions but keep test actions @@ -162,9 +184,7 @@ common:ci-macos --strategy=TestRunner=darwin-sandbox,local # still run against Windows binaries. common:ci-windows-cross --config=ci-windows common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true -common:ci-windows-cross --config=remote common:ci-windows-cross --host_platform=//:rbe -common:ci-windows-cross --strategy=remote common:ci-windows-cross --strategy=TestRunner=local common:ci-windows-cross --local_test_jobs=4 common:ci-windows-cross --test_env=RUST_TEST_THREADS=1 @@ -180,8 +200,6 @@ common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host common:ci-v8 --config=ci common:ci-v8 --build_metadata=TAG_workflow=v8 common:ci-v8 --build_metadata=TAG_os=linux -common:ci-v8 --config=remote -common:ci-v8 --strategy=remote # Source-built Bazel V8 artifacts use the in-process sandbox by default. This # does not affect Cargo's default prebuilt rusty_v8 path. diff --git a/.github/scripts/run-bazel-ci.sh b/.github/scripts/run-bazel-ci.sh index f98e4d8cb9..89f937a998 100755 --- a/.github/scripts/run-bazel-ci.sh +++ b/.github/scripts/run-bazel-ci.sh @@ -53,11 +53,20 @@ fi run_bazel() { if [[ "${RUNNER_OS:-}" == "Windows" ]]; then - MSYS2_ARG_CONV_EXCL='*' bazel "$@" + MSYS2_ARG_CONV_EXCL='*' "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" return fi - bazel "$@" + "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" +} + +run_bazel_with_startup_args() { + if (( ${#bazel_startup_args[@]} > 0 )); then + run_bazel "${bazel_startup_args[@]}" "$@" + return + fi + + run_bazel "$@" } ci_config=ci-linux @@ -77,23 +86,16 @@ esac print_bazel_test_log_tails() { local console_log="$1" local testlogs_dir - local -a bazel_info_cmd=(bazel) + local -a bazel_info_args=(info) - - if (( ${#bazel_startup_args[@]} > 0 )); then - bazel_info_cmd+=("${bazel_startup_args[@]}") - fi - - # `bazel info` needs the same CI config as the failed test invocation so - # platform-specific output roots match. On Windows, omitting `ci-windows` - # would point at `local_windows-fastbuild` even when the test ran with the - # MSVC host platform under `local_windows_msvc-fastbuild`. if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then - bazel_info_args+=( - "--config=${ci_config}" - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - ) + # `bazel info` needs the same CI config as the failed test invocation so + # platform-specific output roots match. On Windows, omitting `ci-windows` + # would point at `local_windows-fastbuild` even when the test ran with the + # MSVC host platform under `local_windows_msvc-fastbuild`. + bazel_info_args+=("--config=${ci_config}") fi + # Only pass flags that affect Bazel's output-root selection or repository # lookup. Test/build-only flags such as execution logs or remote download # mode can make `bazel info` fail, which would hide the real test log path. @@ -105,7 +107,7 @@ print_bazel_test_log_tails() { esac done - testlogs_dir="$(run_bazel "${bazel_info_cmd[@]:1}" \ + testlogs_dir="$(run_bazel_with_startup_args \ --noexperimental_remote_repo_contents_cache \ "${bazel_info_args[@]}" \ bazel-testlogs 2>/dev/null || echo bazel-testlogs)" @@ -254,8 +256,9 @@ if [[ ${#bazel_args[@]} -eq 0 || ${#bazel_targets[@]} -eq 0 ]]; then fi if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then - # Fork PRs do not receive the BuildBuddy secret needed for the remote - # cross-compile config. Preserve the previous local Windows build shape. + # Windows cross-compilation depends on authenticated RBE. Preserve the local + # Windows build shape when credentials are unavailable. + ci_config=ci-windows windows_msvc_host_platform=1 fi @@ -297,9 +300,9 @@ if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -n "${BUI fi if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then - # The Windows cross-compile config depends on remote execution. Fork PRs do - # not receive the BuildBuddy secret, so fall back to the existing local build - # shape and keep its lower concurrency cap. + # The Windows cross-compile config depends on authenticated remote + # execution. When credentials are unavailable, keep the local build shape + # and its lower concurrency cap. post_config_bazel_args+=(--jobs=8) fi @@ -377,70 +380,31 @@ fi bazel_console_log="$(mktemp)" trap 'rm -f "$bazel_console_log"' EXIT -bazel_cmd=(bazel) -if (( ${#bazel_startup_args[@]} > 0 )); then - bazel_cmd+=("${bazel_startup_args[@]}") -fi - +bazel_run_args=( + "${bazel_args[@]}" +) if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then echo "BuildBuddy API key is available; using remote Bazel configuration." - # Work around Bazel 9 remote repo contents cache / overlay materialization failures - # seen in CI (for example "is not a symlink" or permission errors while - # materializing external repos such as rules_perl). We still use BuildBuddy for - # remote execution/cache; this only disables the startup-level repo contents cache. - bazel_run_args=( - "${bazel_args[@]}" - "--config=${ci_config}" - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - ) - if (( ${#post_config_bazel_args[@]} > 0 )); then - bazel_run_args+=("${post_config_bazel_args[@]}") - fi - set +e - run_bazel "${bazel_cmd[@]:1}" \ - --noexperimental_remote_repo_contents_cache \ - "${bazel_run_args[@]}" \ - -- \ - "${bazel_targets[@]}" \ - 2>&1 | tee "$bazel_console_log" - bazel_status=${PIPESTATUS[0]} - set -e + bazel_run_args+=("--config=${ci_config}") else echo "BuildBuddy API key is not available; using local Bazel configuration." - # Keep fork/community PRs on Bazel but disable remote services that are - # configured in .bazelrc and require auth. - # - # Flag docs: - # - Command-line reference: https://bazel.build/reference/command-line-reference - # - Remote caching overview: https://bazel.build/remote/caching - # - Remote execution overview: https://bazel.build/remote/rbe - # - Build Event Protocol overview: https://bazel.build/remote/bep - # - # --noexperimental_remote_repo_contents_cache: - # disable remote repo contents cache enabled in .bazelrc startup options. - # https://bazel.build/reference/command-line-reference#startup_options-flag--experimental_remote_repo_contents_cache - # --remote_cache= and --remote_executor=: - # clear remote cache/execution endpoints configured in .bazelrc. - # https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache - # https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor - bazel_run_args=( - "${bazel_args[@]}" - --remote_cache= - --remote_executor= - ) - if (( ${#post_config_bazel_args[@]} > 0 )); then - bazel_run_args+=("${post_config_bazel_args[@]}") - fi - set +e - run_bazel "${bazel_cmd[@]:1}" \ - --noexperimental_remote_repo_contents_cache \ - "${bazel_run_args[@]}" \ - -- \ - "${bazel_targets[@]}" \ - 2>&1 | tee "$bazel_console_log" - bazel_status=${PIPESTATUS[0]} - set -e fi +if (( ${#post_config_bazel_args[@]} > 0 )); then + bazel_run_args+=("${post_config_bazel_args[@]}") +fi +set +e +# Work around Bazel 9 remote repo contents cache / overlay materialization +# failures seen in CI (for example "is not a symlink" or permission errors +# while materializing external repos such as rules_perl). This only disables +# the startup-level repo contents cache; keyed runs still use BuildBuddy. +run_bazel_with_startup_args \ + --noexperimental_remote_repo_contents_cache \ + "${bazel_run_args[@]}" \ + -- \ + "${bazel_targets[@]}" \ + 2>&1 | tee "$bazel_console_log" +bazel_status=${PIPESTATUS[0]} +set -e if [[ ${bazel_status:-0} -ne 0 ]]; then if [[ $print_failed_bazel_action_summary -eq 1 ]]; then diff --git a/.github/scripts/run-bazel-query-ci.sh b/.github/scripts/run-bazel-query-ci.sh index dd03b67169..f5d4f56f49 100755 --- a/.github/scripts/run-bazel-query-ci.sh +++ b/.github/scripts/run-bazel-query-ci.sh @@ -2,48 +2,17 @@ set -euo pipefail -# Run Bazel queries with the same CI startup settings as the main build/test -# invocation so target-discovery queries can reuse the same Bazel server. +# Run target-discovery queries with the same startup settings as the main +# build/test invocation so they can reuse the same Bazel server. Queries only +# enumerate labels, so they intentionally do not select CI or remote configs. -query_args=() -windows_cross_compile=0 -while [[ $# -gt 0 ]]; do - case "$1" in - --windows-cross-compile) - windows_cross_compile=1 - shift - ;; - --) - shift - break - ;; - *) - query_args+=("$1") - shift - ;; - esac -done - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 [--windows-cross-compile] [...] -- " >&2 +if [[ $# -lt 2 || "${@: -2:1}" != "--" ]]; then + echo "Usage: $0 [...] -- " >&2 exit 1 fi -query_expression="$1" - -ci_config=ci-linux -case "${RUNNER_OS:-}" in - macOS) - ci_config=ci-macos - ;; - Windows) - if [[ $windows_cross_compile -eq 1 ]]; then - ci_config=ci-windows-cross - else - ci_config=ci-windows - fi - ;; -esac +query_args=("${@:1:$#-2}") +query_expression="${@: -1}" bazel_startup_args=() if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then @@ -60,12 +29,6 @@ run_bazel() { } bazel_query_args=(--noexperimental_remote_repo_contents_cache query) -if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then - bazel_query_args+=( - "--config=${ci_config}" - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - ) -fi if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then bazel_query_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}") @@ -75,7 +38,10 @@ if [[ -n "${BAZEL_REPOSITORY_CACHE:-}" ]]; then bazel_query_args+=("--repository_cache=${BAZEL_REPOSITORY_CACHE}") fi -bazel_query_args+=("${query_args[@]}" "$query_expression") +if (( ${#query_args[@]} > 0 )); then + bazel_query_args+=("${query_args[@]}") +fi +bazel_query_args+=("$query_expression") if (( ${#bazel_startup_args[@]} > 0 )); then run_bazel "${bazel_startup_args[@]}" "${bazel_query_args[@]}" diff --git a/.github/scripts/run_bazel_with_buildbuddy.py b/.github/scripts/run_bazel_with_buildbuddy.py new file mode 100755 index 0000000000..add95a99e5 --- /dev/null +++ b/.github/scripts/run_bazel_with_buildbuddy.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 + +import json +import os +import sys +from collections.abc import Mapping +from collections.abc import Sequence +from pathlib import Path + + +OPENAI_REPOSITORY = "openai/codex" +# Remote configurations select cache/BES/download endpoints. Their -rbe forms +# also select the matching remote executor endpoint. +GENERIC_REMOTE_CONFIG = "buildbuddy-generic" +OPENAI_REMOTE_CONFIG = "buildbuddy-openai" +# These CI configurations require remote build execution. The wrapper supplies +# an RBE configuration, which also includes the common `remote` settings. +REMOTE_EXECUTION_CONFIGS = { + "--config=ci-linux", + "--config=ci-macos", + "--config=ci-v8", + "--config=ci-windows-cross", +} +# Only authenticated workflow runs executing trusted upstream code may use the +# OpenAI BuildBuddy host. A pull request event without proof that its head is +# in the upstream repository fails closed to the generic host. +def is_trusted_upstream_run(env: Mapping[str, str]) -> bool: + # `GITHUB_REPOSITORY` is easy to set locally. Requiring GitHub's workflow + # marker prevents a local command from opting itself into the OpenAI host. + if ( + env.get("GITHUB_ACTIONS") != "true" + or env.get("GITHUB_REPOSITORY") != OPENAI_REPOSITORY + ): + return False + # Non-PR workflow runs in `openai/codex` execute upstream refs, so they are + # trusted. Fork code reaches these workflows only through pull requests. + if env.get("GITHUB_EVENT_NAME") != "pull_request": + return True + + event_path = env.get("GITHUB_EVENT_PATH") + if not event_path: + return False + try: + event = json.loads(Path(event_path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + + try: + return event["pull_request"]["head"]["repo"]["fork"] is False + except (KeyError, TypeError): + return False + + +def uses_openai_host(env: Mapping[str, str]) -> bool: + return bool(env.get("BUILDBUDDY_API_KEY")) and is_trusted_upstream_run(env) + + +def uses_remote_execution(args: Sequence[str]) -> bool: + try: + separator_idx = args.index("--") + except ValueError: + separator_idx = len(args) + return any(arg in REMOTE_EXECUTION_CONFIGS for arg in args[:separator_idx]) + + +def remote_config(args: Sequence[str], env: Mapping[str, str]) -> str | None: + if not env.get("BUILDBUDDY_API_KEY"): + return None + + config = OPENAI_REMOTE_CONFIG if uses_openai_host(env) else GENERIC_REMOTE_CONFIG + if uses_remote_execution(args): + config += "-rbe" + return config + + +def bazel_args_without_remote_execution(args: Sequence[str]) -> list[str]: + # Remote CI configs require BuildBuddy credentials. Removing them preserves + # the local fallback used for fork pull requests. + try: + separator_idx = args.index("--") + except ValueError: + separator_idx = len(args) + return [ + *(arg for arg in args[:separator_idx] if arg not in REMOTE_EXECUTION_CONFIGS), + *args[separator_idx:], + ] + + +def bazel_args_with_remote_config( + args: Sequence[str], env: Mapping[str, str] +) -> list[str]: + config = remote_config(args, env) + if config is None: + return bazel_args_without_remote_execution(args) + + # `remote_config()` returns a configuration only when this key is present. + api_key = env["BUILDBUDDY_API_KEY"] + remote_args = [ + f"--config={config}", + f"--remote_header=x-buildbuddy-api-key={api_key}", + ] + + # Insert immediately after the Bazel command. This keeps wrapper-added + # options out of positional payloads and lets later CI configs override + # shared RBE defaults such as the Windows cross-compilation exec platforms. + insertion_idx = next( + (idx + 1 for idx, arg in enumerate(args) if not arg.startswith("-")), + len(args), + ) + return [*args[:insertion_idx], *remote_args, *args[insertion_idx:]] + + +def bazel_command(*args: str, env: Mapping[str, str] | None = None) -> list[str]: + env = os.environ if env is None else env + bazel = env.get("CODEX_BAZEL_BIN", "bazel") + return [bazel, *bazel_args_with_remote_config(args, env)] + + +def main() -> None: + config = remote_config(sys.argv[1:], os.environ) + if config is None: + print( + "BuildBuddy key unavailable; using local Bazel configuration.", + file=sys.stderr, + ) + else: + host_description = ( + "OpenAI tenant" if uses_openai_host(os.environ) else "generic" + ) + print( + f"Using {host_description} BuildBuddy configuration: {config}.", + file=sys.stderr, + ) + + command = bazel_command(*sys.argv[1:]) + # Replace the wrapper so Bazel receives signals directly and supplies the + # command exit status; a subprocess parent would have no remaining work. + os.execvp(command[0], command) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/rusty_v8_bazel.py b/.github/scripts/rusty_v8_bazel.py index 2f46daf45e..329d3f6c54 100644 --- a/.github/scripts/rusty_v8_bazel.py +++ b/.github/scripts/rusty_v8_bazel.py @@ -5,7 +5,6 @@ from __future__ import annotations import argparse import gzip import hashlib -import os import re import shutil import subprocess @@ -13,6 +12,7 @@ import sys import tomllib from pathlib import Path +from run_bazel_with_buildbuddy import bazel_command from rusty_v8_module_bazel import ( RustyV8ChecksumError, check_module_bazel, @@ -29,33 +29,22 @@ SANDBOX_ARTIFACT_PROFILE = "ptrcomp_sandbox_release" ARTIFACT_BAZEL_CONFIGS = ["rusty-v8-upstream-libcxx"] -def bazel_remote_args() -> list[str]: - buildbuddy_api_key = os.environ.get("BUILDBUDDY_API_KEY") - if not buildbuddy_api_key: - return [] - return [f"--remote_header=x-buildbuddy-api-key={buildbuddy_api_key}"] - - def bazel_execroot() -> Path: - result = subprocess.run( - ["bazel", "info", "execution_root"], + output = subprocess.check_output( + bazel_command("info", "execution_root"), cwd=ROOT, - check=True, - capture_output=True, text=True, ) - return Path(result.stdout.strip()) + return Path(output.strip()) def bazel_output_base() -> Path: - result = subprocess.run( - ["bazel", "info", "output_base"], + output = subprocess.check_output( + bazel_command("info", "output_base"), cwd=ROOT, - check=True, - capture_output=True, text=True, ) - return Path(result.stdout.strip()) + return Path(output.strip()) def bazel_output_path(path: str) -> Path: @@ -72,24 +61,22 @@ def bazel_output_files( ) -> list[Path]: expression = "set(" + " ".join(labels) + ")" bazel_configs = bazel_configs or [] - result = subprocess.run( - [ - "bazel", + output = subprocess.check_output( + bazel_command( "cquery", "-c", compilation_mode, f"--platforms=@llvm//platforms:{platform}", *[f"--config={config}" for config in bazel_configs], - *bazel_remote_args(), "--output=files", expression, - ], + ), cwd=ROOT, - check=True, - capture_output=True, text=True, ) - return [bazel_output_path(line.strip()) for line in result.stdout.splitlines() if line.strip()] + return [ + bazel_output_path(line.strip()) for line in output.splitlines() if line.strip() + ] def bazel_build( @@ -102,17 +89,15 @@ def bazel_build( bazel_configs = bazel_configs or [] download_args = ["--remote_download_toplevel"] if download_toplevel else [] subprocess.run( - [ - "bazel", + bazel_command( "build", "-c", compilation_mode, f"--platforms=@llvm//platforms:{platform}", *[f"--config={config}" for config in bazel_configs], - *bazel_remote_args(), *download_args, *labels, - ], + ), cwd=ROOT, check=True, ) @@ -172,7 +157,7 @@ def resolved_v8_crate_version() -> str: matches = sorted( set( re.findall( - r'https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate', + r"https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate", module_bazel, ) ) @@ -234,13 +219,17 @@ def stage_artifacts( output_dir: Path, sandbox: bool, ) -> None: - missing_paths = [str(path) for path in [lib_path, binding_path] if not path.exists()] + missing_paths = [ + str(path) for path in [lib_path, binding_path] if not path.exists() + ] if missing_paths: raise SystemExit(f"missing release outputs for {target}: {missing_paths}") output_dir.mkdir(parents=True, exist_ok=True) artifact_profile = SANDBOX_ARTIFACT_PROFILE if sandbox else RELEASE_ARTIFACT_PROFILE - staged_library = output_dir / staged_archive_name(target, lib_path, artifact_profile) + staged_library = output_dir / staged_archive_name( + target, lib_path, artifact_profile + ) staged_binding = output_dir / staged_binding_name(target, artifact_profile) with lib_path.open("rb") as src, staged_library.open("wb") as dst: @@ -270,7 +259,9 @@ def stage_artifacts( def upstream_release_pair_paths(source_root: Path, target: str) -> tuple[Path, Path]: - lib_name = "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a" + lib_name = ( + "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a" + ) gn_out = source_root / "target" / target / "release" / "gn_out" return gn_out / "obj" / lib_name, gn_out / "src_binding.rs" @@ -338,7 +329,9 @@ def parse_args() -> argparse.Namespace: stage_upstream_release_pair_parser = subparsers.add_parser( "stage-upstream-release-pair" ) - stage_upstream_release_pair_parser.add_argument("--source-root", type=Path, required=True) + stage_upstream_release_pair_parser.add_argument( + "--source-root", type=Path, required=True + ) stage_upstream_release_pair_parser.add_argument("--target", required=True) stage_upstream_release_pair_parser.add_argument("--output-dir", required=True) stage_upstream_release_pair_parser.add_argument("--sandbox", action="store_true") diff --git a/.github/scripts/test_run_bazel_with_buildbuddy.py b/.github/scripts/test_run_bazel_with_buildbuddy.py new file mode 100644 index 0000000000..bab4ad5cce --- /dev/null +++ b/.github/scripts/test_run_bazel_with_buildbuddy.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 + +import json +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +import run_bazel_with_buildbuddy + + +class RunBazelWithBuildBuddyTest(unittest.TestCase): + def github_env( + self, + temp_dir: str, + *, + repository: str = "openai/codex", + fork: bool = False, + event_name: str = "pull_request", + ) -> dict[str, str]: + event_path = Path(temp_dir) / "event.json" + event_path.write_text( + json.dumps({"pull_request": {"head": {"repo": {"fork": fork}}}}), + encoding="utf-8", + ) + return { + "BUILDBUDDY_API_KEY": "token", + "GITHUB_ACTIONS": "true", + "GITHUB_EVENT_NAME": event_name, + "GITHUB_EVENT_PATH": str(event_path), + "GITHUB_REPOSITORY": repository, + } + + def test_keyless_invocation_drops_remote_ci_configuration(self) -> None: + self.assertIsNone( + run_bazel_with_buildbuddy.remote_config( + ["build", "--config=ci-linux", "//codex-rs/cli:codex"], + {}, + ) + ) + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config( + ["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"], + {}, + ), + ["build", "--", "//codex-rs/cli:codex"], + ) + + def test_program_arguments_after_separator_do_not_select_or_lose_rbe(self) -> None: + args = ["run", "//codex-rs/cli:codex", "--", "--config=remote"] + + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config(args, {}), + args, + ) + self.assertEqual( + run_bazel_with_buildbuddy.remote_config( + args, {"BUILDBUDDY_API_KEY": "fork-token"} + ), + "buildbuddy-generic", + ) + + def test_upstream_push_selects_openai_rbe_before_target_separator(self) -> None: + with TemporaryDirectory() as temp_dir: + env = self.github_env(temp_dir, event_name="push") + + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config( + ["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"], + env, + ), + [ + "build", + "--config=buildbuddy-openai-rbe", + "--remote_header=x-buildbuddy-api-key=token", + "--config=ci-linux", + "--", + "//codex-rs/cli:codex", + ], + ) + + def test_windows_cross_ci_configuration_follows_remote_configuration(self) -> None: + env = {"BUILDBUDDY_API_KEY": "fork-token"} + + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config( + ["build", "--config=ci-windows-cross", "//codex-rs/cli:codex"], + env, + ), + [ + "build", + "--config=buildbuddy-generic-rbe", + "--remote_header=x-buildbuddy-api-key=fork-token", + "--config=ci-windows-cross", + "//codex-rs/cli:codex", + ], + ) + + def test_query_remote_configuration_is_inserted_before_expression(self) -> None: + expression = 'kind("rust_library rule", //codex-rs/...)' + env = {"BUILDBUDDY_API_KEY": "fork-token"} + + for command in ("query", "cquery", "aquery"): + with self.subTest(command=command): + self.assertEqual( + run_bazel_with_buildbuddy.bazel_args_with_remote_config( + [ + command, + "--config=ci-windows-cross", + "--output=label", + expression, + ], + env, + ), + [ + command, + "--config=buildbuddy-generic-rbe", + "--remote_header=x-buildbuddy-api-key=fork-token", + "--config=ci-windows-cross", + "--output=label", + expression, + ], + ) + + def test_same_repository_pull_request_selects_openai_host(self) -> None: + with TemporaryDirectory() as temp_dir: + self.assertEqual( + run_bazel_with_buildbuddy.remote_config( + ["build", "--config=ci-v8"], self.github_env(temp_dir) + ), + "buildbuddy-openai-rbe", + ) + + def test_fork_pull_request_cannot_select_openai_host(self) -> None: + with TemporaryDirectory() as temp_dir: + env = self.github_env(temp_dir, fork=True) + + self.assertEqual( + run_bazel_with_buildbuddy.remote_config( + ["build", "--config=ci-v8"], env + ), + "buildbuddy-generic-rbe", + ) + + def test_run_in_fork_repository_cannot_select_openai_host(self) -> None: + with TemporaryDirectory() as temp_dir: + env = self.github_env(temp_dir, repository="contributor/codex") + + self.assertEqual( + run_bazel_with_buildbuddy.remote_config( + ["build", "--config=ci-v8"], env + ), + "buildbuddy-generic-rbe", + ) + + def test_pull_request_without_readable_event_payload_fails_closed(self) -> None: + for event_path in (None, "missing-event.json"): + env = { + "BUILDBUDDY_API_KEY": "token", + "GITHUB_ACTIONS": "true", + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_REPOSITORY": "openai/codex", + } + if event_path is not None: + env["GITHUB_EVENT_PATH"] = event_path + + with self.subTest(event_path=event_path): + self.assertEqual( + run_bazel_with_buildbuddy.remote_config(["build"], env), + "buildbuddy-generic", + ) + + def test_bazel_command_uses_configured_binary_locally(self) -> None: + self.assertEqual( + run_bazel_with_buildbuddy.bazel_command( + "info", + "execution_root", + env={"CODEX_BAZEL_BIN": "fake-bazel"}, + ), + ["fake-bazel", "info", "execution_root"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_rusty_v8_bazel.py b/.github/scripts/test_rusty_v8_bazel.py index 19690dbece..0b5c03f436 100644 --- a/.github/scripts/test_rusty_v8_bazel.py +++ b/.github/scripts/test_rusty_v8_bazel.py @@ -88,24 +88,49 @@ class RustyV8BazelTest(unittest.TestCase): ), ) - def test_bazel_remote_args_include_buildbuddy_header_when_present(self) -> None: - with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=False): + def test_bazel_commands_use_shared_buildbuddy_remote_config_library(self) -> None: + with patch.dict(environ, {}, clear=True): self.assertEqual( - ["--remote_header=x-buildbuddy-api-key=token"], - rusty_v8_bazel.bazel_remote_args(), + [ + "bazel", + "build", + "//third_party/v8:release", + ], + rusty_v8_bazel.bazel_command( + "build", + "--config=ci-v8", + "//third_party/v8:release", + ), + ) + with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=True): + self.assertEqual( + [ + "bazel", + "build", + "--config=buildbuddy-generic-rbe", + "--remote_header=x-buildbuddy-api-key=token", + "--config=ci-v8", + "//third_party/v8:release", + ], + rusty_v8_bazel.bazel_command( + "build", + "--config=ci-v8", + "//third_party/v8:release", + ), ) - with patch.dict(environ, {}, clear=True): - self.assertEqual([], rusty_v8_bazel.bazel_remote_args()) - - def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts(self) -> None: + def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts( + self, + ) -> None: self.assertEqual( "//third_party/v8:rusty_v8_release_pair_x86_64_unknown_linux_musl", rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl"), ) self.assertEqual( "//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_unknown_linux_musl", - rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl", sandbox=True), + rusty_v8_bazel.release_pair_label( + "x86_64-unknown-linux-musl", sandbox=True + ), ) self.assertEqual( "//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_apple_darwin", @@ -205,11 +230,7 @@ class RustyV8BazelTest(unittest.TestCase): with TemporaryDirectory() as source_dir, TemporaryDirectory() as output_dir: source_root = Path(source_dir) gn_out = ( - source_root - / "target" - / "x86_64-pc-windows-msvc" - / "release" - / "gn_out" + source_root / "target" / "x86_64-pc-windows-msvc" / "release" / "gn_out" ) (gn_out / "obj").mkdir(parents=True) (gn_out / "obj" / "rusty_v8.lib").write_bytes(b"archive") diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 153ace0fc9..e2782b4904 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -15,6 +15,7 @@ concurrency: # See https://docs.github.com/en/actions/using-jobs/using-concurrency and https://docs.github.com/en/actions/learn-github-actions/contexts for more info. group: concurrency-group::${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}${{ github.ref_name == 'main' && format('::{0}', github.run_id) || ''}} cancel-in-progress: ${{ github.ref_name != 'main' }} + jobs: test: # PRs use the sharded Windows cross-compiled test jobs below. Post-merge @@ -55,12 +56,17 @@ 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' + with: + tool: just + - name: Check rusty_v8 MODULE.bazel checksums if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' shell: bash run: | python3 .github/scripts/rusty_v8_bazel.py check-module-bazel - python3 -m unittest discover -s .github/scripts -p test_rusty_v8_bazel.py + just test-github-scripts - name: Prepare Bazel CI id: prepare_bazel diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index 4b5320dcb9..d6fb73e96d 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -191,11 +191,10 @@ jobs: bazel_args+=(--config=v8-release-compat) fi - bazel \ + ./.github/scripts/run_bazel_with_buildbuddy.py \ --noexperimental_remote_repo_contents_cache \ "${bazel_args[@]}" \ - "--config=${{ matrix.bazel_config }}" \ - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" + "--config=${{ matrix.bazel_config }}" - name: Stage release pair env: diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 71ce5d7865..0ad9f850d1 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -5,6 +5,7 @@ on: paths: - ".bazelrc" - ".github/actions/setup-bazel-ci/**" + - ".github/scripts/run_bazel_with_buildbuddy.py" - ".github/scripts/rusty_v8_bazel.py" - ".github/scripts/rusty_v8_module_bazel.py" - ".github/workflows/rusty-v8-release.yml" @@ -23,6 +24,7 @@ on: paths: - ".bazelrc" - ".github/actions/setup-bazel-ci/**" + - ".github/scripts/run_bazel_with_buildbuddy.py" - ".github/scripts/rusty_v8_bazel.py" - ".github/scripts/rusty_v8_module_bazel.py" - ".github/workflows/rusty-v8-release.yml" @@ -203,11 +205,10 @@ jobs: bazel_args+=(--config=v8-release-compat) fi - bazel \ + ./.github/scripts/run_bazel_with_buildbuddy.py \ --noexperimental_remote_repo_contents_cache \ "${bazel_args[@]}" \ - "--config=${{ matrix.bazel_config }}" \ - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" + "--config=${{ matrix.bazel_config }}" - name: Stage release pair env: diff --git a/codex-rs/docs/bazel.md b/codex-rs/docs/bazel.md index a124688a24..085c15992f 100644 --- a/codex-rs/docs/bazel.md +++ b/codex-rs/docs/bazel.md @@ -4,7 +4,7 @@ This repository uses Bazel to build the Rust workspace under `codex-rs`. Cargo remains the source of truth for crates and features, while Bazel provides hermetic builds, toolchains, and cross-platform artifacts. -As of 1/9/2026, this setup is still experimental as we stabilize it. +As of 6/1/2026, this setup is still experimental as we stabilize it. ## High-level layout @@ -20,6 +20,118 @@ As of 1/9/2026, this setup is still experimental as we stabilize it. makes some adjustments if the crate needs additional compile-time or runtime data, or other customizations. +## Running Bazel locally + +The repository root `justfile` exposes the common Bazel entry points: + +```bash +just bazel-test +just bazel-clippy +``` + +Ordinary local `bazel` and `just` invocations run locally. BuildBuddy cache, +build event upload, downloads, and remote execution are opt-in configurations. + +## BuildBuddy + +Codex uses BuildBuddy for a shared Bazel cache and remoted builds and tests. To use it +to speed up your builds and tests you'll need to provide an API key and select a +configuration. + +### BuildBuddy API key + +If you're an OpenAI employee, log in to https://openai.buildbuddy.io and use Google sign-in. + +Create a BuildBuddy API key as described in BuildBuddy's [Authentication Guide][bb-auth-guide], +then add it to `~/.bazelrc`: + +```bazelrc +# Local machine only; this file contains a BuildBuddy credential. +common --remote_header=x-buildbuddy-api-key= +``` + +Keeping the credential outside the workspace reduces the risk of accidentally +committing it. + +If you need different API keys for different projects, put the API key in +`%workspace%/user.bazelrc` instead. The checked-in `.bazelrc` optionally imports +that file, and `.gitignore` excludes it. Do not commit or share a file containing +the credential. + +[bb-auth-guide]: https://www.buildbuddy.io/docs/guide-auth/#managing-keys + +### Selecting a remote build configuration + +OpenAI employees should default to the OpenAI host with remote execution unless +they have a reason to choose another configuration. Add the following configuration +to `%workspace%/user.bazelrc`: + +```bazelrc +common --config=buildbuddy-openai-rbe +``` + +OpenAI employees who don't want remote execution can use `buildbuddy-openai`. External users +should use `buildbuddy-generic-rbe` or `buildbuddy-generic`. See below for details on these +configurations. + +### All remote configurations + +GitHub Actions routes Bazel build and output-resolution commands through +`.github/scripts/run_bazel_with_buildbuddy.py`. Higher-level helpers such as +`.github/scripts/run-bazel-ci.sh` and `.github/scripts/rusty_v8_bazel.py` +delegate remote configuration selection to that wrapper. The wrapper reads the +GitHub Actions repository and event payload rather than relying on workflow +files to duplicate tenant-selection logic. + +Loading-phase target-discovery `bazel query` commands run locally because they +only enumerate labels and do not need remote caches or execution. + +The `Cache/BES` host is also used for remote downloads. + +| Invocation/config | Key Required | Cache/BES | Build exec | Test exec | +| --- | --- | --- | --- | --- | +| `bazel ...` | No | None | Local | Local | +| `bazel ... --config=buildbuddy-generic` | Yes | `remote.buildbuddy.io` | Local | Local | +| `bazel ... --config=buildbuddy-generic-rbe` | Yes | `remote.buildbuddy.io` | Remote | Remote | +| `bazel ... --config=buildbuddy-openai` | Yes | `openai.buildbuddy.io` | Local | Local | +| `bazel ... --config=buildbuddy-openai-rbe` | Yes | `openai.buildbuddy.io` | Remote | Remote | + +Without an API key, the wrapper removes remote CI configurations and runs +locally. With a key, workflows choose the host as follows: + +| Run | Key | Uses OpenAI BuildBuddy Host | +| --- | --- | --- | +| Push to `main` in `openai/codex` | Yes | Yes | +| `workflow_dispatch` in `openai/codex` | Yes | Yes | +| Same-repository pull request in `openai/codex` | Yes | Yes | +| Fork pull request into `openai/codex` | No | No; local | +| Push or `workflow_dispatch` in a fork with a key | Yes | No; generic host | +| Pull request run in a fork repository with a key | Yes | No; generic host | + +CI configurations determine whether builds and tests execute remotely: + +| CI config | Remote config | Build exec | Test exec | +| --- | --- | --- | --- | +| `ci-linux` | `*-rbe` | Remote host | Remote host | +| `ci-v8` | `*-rbe` | Remote host | Remote host | +| `ci-macos` | `*-rbe` | Remote host | Local | +| `ci-windows-cross` | `*-rbe` | Remote host | Local | +| `ci-windows` | non-RBE | Local | Local | +| Keyless CI fallback | none | Local | Local | + +To exercise the generic remote configuration with your key: + +```bash +BUILDBUDDY_API_KEY=... GITHUB_REPOSITORY=my-fork/codex \ + ./.github/scripts/run_bazel_with_buildbuddy.py \ + build --config=ci-linux //codex-rs/cli:codex +``` + +The wrapper selects the OpenAI host only inside GitHub Actions for a trusted +run in `openai/codex`. A missing or malformed pull request event +payload fails closed to the generic host. For local OpenAI host access, use +the `user.bazelrc` configuration above. + ## Evolving the setup When you add or change Rust dependencies, update the Cargo.toml/Cargo.lock as normal. diff --git a/justfile b/justfile index 34b5115ee0..fe7e7349b3 100644 --- a/justfile +++ b/justfile @@ -83,6 +83,12 @@ test *args: $env:RUST_MIN_STACK = "{{ rust_min_stack }}"; cargo nextest run --no-fail-fast @($args | Select-Object -Skip 1) just bench-smoke +# Run from the repository root so scripts that resolve paths from `cwd` see +# the same layout they use in GitHub Actions. +[no-cd] +test-github-scripts: + {{ python }} -m unittest discover -s {{ justfile_directory() }}/.github/scripts -p 'test_*.py' + # Run explicit workspace benchmark targets. bench *args: cargo bench --workspace --bench '*' {args} @@ -129,11 +135,8 @@ bazel-clippy: bazel-argument-comment-lint: bazel build --config=argument-comment-lint -- $({{ justfile_directory() }}/tools/argument-comment-lint/list-bazel-targets.sh) -bazel-remote-test: - bazel test --test_tag_filters=-argument-comment-lint //... --config=remote --platforms=//:rbe --keep_going - build-for-release: - bazel build //codex-rs/cli:release_binaries --config=remote + bazel build //codex-rs/cli:release_binaries # Run the MCP server mcp-server-run *args: diff --git a/scripts/list-bazel-clippy-targets.sh b/scripts/list-bazel-clippy-targets.sh index b76fc2a830..d12a256d00 100755 --- a/scripts/list-bazel-clippy-targets.sh +++ b/scripts/list-bazel-clippy-targets.sh @@ -20,23 +20,13 @@ while [[ $# -gt 0 ]]; do done # Resolve the dynamic targets before printing anything so callers do not -# continue with a partial list if `bazel query` fails. Reuse the same CI Bazel -# server settings as the subsequent build so Windows jobs do not cold-start a -# second Bazel server just for target discovery. -if [[ $windows_cross_compile -eq 1 ]]; then - manual_rust_test_targets="$( - ./.github/scripts/run-bazel-query-ci.sh \ - --windows-cross-compile \ - --output=label \ - -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' - )" -else - manual_rust_test_targets="$( - ./.github/scripts/run-bazel-query-ci.sh \ - --output=label \ - -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' - )" -fi +# continue with a partial list if `bazel query` fails. Target discovery is +# local on all platforms. +manual_rust_test_targets="$( + ./.github/scripts/run-bazel-query-ci.sh \ + --output=label \ + -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' +)" if [[ "${RUNNER_OS:-}" != "Windows" ]]; then # Non-Windows clippy jobs lint the native test binaries; the # Windows-cross binaries exist only for the fast Windows test leg. From 593df8773d8deeff7d778fe39b59420ce293d7a5 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 2 Jun 2026 10:57:35 -0700 Subject: [PATCH 02/19] core: derive built-in permission profiles from raw policies (#25739) ## Why Permission profiles that extend a built-in profile should behave like other TOML inheritance: parent entries provide defaults, and child keys override matching fields before the profile is compiled. That was not true for `:workspace`. Previously, a profile with `extends = ":workspace"` seeded the compiled runtime `PermissionProfile::workspace_write()` policy and then appended child filesystem entries. A child override such as `":tmpdir" = "read"` therefore left the inherited `":tmpdir" = "write"` entry in the final policy. Since same-target `write` wins over `read` during runtime resolution, the child override was ineffective. This also needs a clear source of truth for the built-in profiles. The protocol-level sandbox policy constructors now define the raw built-in filesystem entries, and both `PermissionProfile` presets and config-profile inheritance derive from those same values. ## What Changed - Add a canonical `FileSystemSandboxPolicy::read_only()` constructor while keeping the read-only and workspace-write raw filesystem entries explicit and independent. - Derive `PermissionProfile::read_only()` from `FileSystemSandboxPolicy::read_only()`; `PermissionProfile::workspace_write()` continues to derive from `FileSystemSandboxPolicy::workspace_write()`. - Build extensible `:read-only` and `:workspace` parent profiles by projecting those canonical sandbox policies into `PermissionProfileToml`, then merge user overrides at the TOML layer before compilation. - Add config parsing support for `:slash_tmp` so the built-in `:workspace` parent can be expressed in the same TOML-shaped filesystem table as user profiles. - Document that `PermissionsToml::resolve_profile()` returns an already-merged `PermissionProfileToml`, and return that profile directly after removing the resolved-profile wrapper. - Extend the config test for `extends = ":workspace"` to assert that inherited `":slash_tmp" = "write"` is preserved and that a child `":tmpdir" = "read"` entry replaces the inherited `write` entry. ## Verification - `just test -p codex-config` - `just test -p codex-protocol` - `just test -p codex-core permissions_profiles_resolve_extends_parent_first_with_child_overrides` - `just test -p codex-core default_permissions_profile_can_extend_builtin_workspace` - `just test -p codex-core` - Result: 2596 passed, 4 failed, 1 timed out. - The failures were existing sandbox/environment-sensitive tests unrelated to this permissions change: `suite::user_shell_cmd::user_shell_command_does_not_set_network_sandbox_env_var`, `suite::user_shell_cmd::user_shell_command_history_is_persisted_and_shared_with_model`, `suite::abort_tasks::interrupt_persists_turn_aborted_marker_in_next_request`, `suite::abort_tasks::interrupt_tool_records_history_entries`, and `thread_manager::tests::start_thread_uses_all_default_environments_from_codex_home`. --- codex-rs/config/src/permissions_toml.rs | 25 +-- codex-rs/core/src/config/config_tests.rs | 44 ++++- codex-rs/core/src/config/permissions.rs | 160 ++++++++++++++---- codex-rs/core/src/config/permissions_tests.rs | 3 +- codex-rs/core/src/network_proxy_loader.rs | 2 +- codex-rs/protocol/src/models.rs | 11 +- codex-rs/protocol/src/permissions.rs | 24 +-- 7 files changed, 195 insertions(+), 74 deletions(-) diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index 0baec2a5cc..988e21e539 100644 --- a/codex-rs/config/src/permissions_toml.rs +++ b/codex-rs/config/src/permissions_toml.rs @@ -30,11 +30,18 @@ impl PermissionsToml { self.entries.is_empty() } + /// Resolve `profile_name` and all of its `extends` ancestors into one TOML + /// profile. + /// + /// Parent profiles are merged before their children, so child keys override + /// matching parent keys before callers compile the profile into runtime + /// permissions. The returned profile keeps the selected profile's + /// declaration metadata, such as `description` and `extends`. pub fn resolve_profile( &self, profile_name: &str, mut parent_profile: F, - ) -> Result + ) -> Result where F: FnMut(&str) -> Option, { @@ -96,10 +103,7 @@ impl PermissionsToml { .into_iter() .rev() .try_fold(profile, merge_permission_profiles)?; - return Ok(ResolvedPermissionProfileToml { - profile, - inherited_profile_names: profile_names.into_iter().skip(1).collect(), - }); + return Ok(profile); } } } @@ -114,17 +118,6 @@ pub struct PermissionProfileToml { pub network: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ResolvedPermissionProfileToml { - pub profile: PermissionProfileToml, - /// Names of profiles inherited while resolving `profile`, ordered from the - /// selected profile's direct parent to the farthest ancestor. - /// - /// Callers use this to preserve which built-in baseline contributed the - /// resolved permissions after the parent profiles have been merged away. - pub inherited_profile_names: Vec, -} - #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum PermissionProfileResolutionError { #[error("default_permissions refers to undefined profile `{profile_name}`")] diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 05cbb9828f..65d20d94f0 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -2357,7 +2357,13 @@ async fn default_permissions_profile_can_extend_builtin_workspace() -> std::io:: description: None, extends: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), workspace_roots: None, - filesystem: None, + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":tmpdir".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), network: Some(NetworkToml { enabled: Some(true), ..Default::default() @@ -2384,6 +2390,42 @@ async fn default_permissions_profile_can_extend_builtin_workspace() -> std::io:: !policy.can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path()), "expected profile extending :workspace to keep metadata carveouts, policy: {policy:?}" ); + assert!( + policy.entries.iter().any(|entry| matches!( + entry, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::SlashTmp, + }, + access: FileSystemAccessMode::Write, + } + )), + "expected profile extending :workspace to keep inherited :slash_tmp writes, policy: {policy:?}" + ); + assert!( + policy.entries.iter().any(|entry| matches!( + entry, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Tmpdir, + }, + access: FileSystemAccessMode::Read, + } + )), + "expected child :tmpdir read entry to replace the inherited write entry, policy: {policy:?}" + ); + assert!( + !policy.entries.iter().any(|entry| matches!( + entry, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Tmpdir, + }, + access: FileSystemAccessMode::Write, + } + )), + "expected inherited :tmpdir write entry to be removed, policy: {policy:?}" + ); assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Enabled diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index a8546858f1..f683d9c7eb 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::collections::BTreeMap; use std::io; use std::path::Component; use std::path::Path; @@ -13,7 +14,6 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml; use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; -use codex_config::permissions_toml::ResolvedPermissionProfileToml; use codex_config::permissions_toml::WorkspaceRootsToml; use codex_config::types::SandboxWorkspaceWrite; use codex_features::NetworkProxyConfigToml; @@ -194,27 +194,132 @@ pub(crate) fn apply_network_proxy_feature_config( pub(crate) fn resolve_permission_profile( permissions: &PermissionsToml, profile_name: &str, -) -> io::Result { +) -> io::Result { permissions - .resolve_profile(profile_name, extensible_builtin_parent_profile_marker) + .resolve_profile(profile_name, extensible_builtin_parent_profile) .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err.to_string())) } -/// Built-in parents provide their runtime permissions below. Resolution only -/// needs an empty profile marker so inheritance can terminate while preserving -/// the built-in parent id in `inherited_profile_names`. -fn extensible_builtin_parent_profile_marker(profile_name: &str) -> Option { - matches!( - profile_name, - BUILT_IN_READ_ONLY_PROFILE | BUILT_IN_WORKSPACE_PROFILE - ) - .then_some(PermissionProfileToml { +fn extensible_builtin_parent_profile(profile_name: &str) -> Option { + let file_system = match profile_name { + BUILT_IN_READ_ONLY_PROFILE => FileSystemSandboxPolicy::read_only(), + BUILT_IN_WORKSPACE_PROFILE => FileSystemSandboxPolicy::workspace_write( + &[], + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ), + _ => return None, + }; + Some(permission_profile_toml_from_file_system_policy(file_system)) +} + +fn permission_profile_toml_from_file_system_policy( + file_system: FileSystemSandboxPolicy, +) -> PermissionProfileToml { + let mut filesystem = FilesystemPermissionsToml { + glob_scan_max_depth: file_system.glob_scan_max_depth, + entries: BTreeMap::new(), + }; + for entry in file_system.entries { + insert_filesystem_permission_toml(&mut filesystem.entries, entry); + } + PermissionProfileToml { description: None, extends: None, workspace_roots: None, - filesystem: None, + filesystem: Some(filesystem), network: None, - }) + } +} + +fn insert_filesystem_permission_toml( + entries: &mut BTreeMap, + entry: FileSystemSandboxEntry, +) { + match entry.path { + FileSystemPath::Path { path } => { + entries.insert( + path.into_path_buf().to_string_lossy().into_owned(), + FilesystemPermissionToml::Access(entry.access), + ); + } + FileSystemPath::GlobPattern { pattern } => { + entries.insert(pattern, FilesystemPermissionToml::Access(entry.access)); + } + FileSystemPath::Special { value } => { + insert_special_filesystem_permission_toml(entries, value, entry.access); + } + } +} + +fn insert_special_filesystem_permission_toml( + entries: &mut BTreeMap, + value: FileSystemSpecialPath, + access: FileSystemAccessMode, +) { + match value { + FileSystemSpecialPath::Root => { + entries.insert( + ":root".to_string(), + FilesystemPermissionToml::Access(access), + ); + } + FileSystemSpecialPath::Minimal => { + entries.insert( + ":minimal".to_string(), + FilesystemPermissionToml::Access(access), + ); + } + FileSystemSpecialPath::ProjectRoots { subpath } => { + insert_scoped_filesystem_permission_toml( + entries, + ":workspace_roots".to_string(), + subpath.unwrap_or_else(|| PathBuf::from(".")), + access, + ); + } + FileSystemSpecialPath::Tmpdir => { + entries.insert( + ":tmpdir".to_string(), + FilesystemPermissionToml::Access(access), + ); + } + FileSystemSpecialPath::SlashTmp => { + entries.insert( + ":slash_tmp".to_string(), + FilesystemPermissionToml::Access(access), + ); + } + FileSystemSpecialPath::Unknown { path, subpath } => { + if let Some(subpath) = subpath { + insert_scoped_filesystem_permission_toml(entries, path, subpath, access); + } else { + entries.insert(path, FilesystemPermissionToml::Access(access)); + } + } + }; +} + +fn insert_scoped_filesystem_permission_toml( + entries: &mut BTreeMap, + path: String, + subpath: PathBuf, + access: FileSystemAccessMode, +) { + let permission = entries + .entry(path) + .or_insert_with(|| FilesystemPermissionToml::Scoped(BTreeMap::new())); + match permission { + FilesystemPermissionToml::Scoped(scoped_entries) => { + scoped_entries.insert(subpath.to_string_lossy().into_owned(), access); + } + FilesystemPermissionToml::Access(_) => { + *permission = FilesystemPermissionToml::Scoped(BTreeMap::from([( + subpath.to_string_lossy().into_owned(), + access, + )])); + } + } } pub(crate) fn network_proxy_config_for_profile_selection( @@ -232,7 +337,7 @@ pub(crate) fn network_proxy_config_for_profile_selection( "default_permissions requires a `[permissions]` table", ) })?; - let profile = resolve_permission_profile(permissions, profile_name)?.profile; + let profile = resolve_permission_profile(permissions, profile_name)?; Ok(network_proxy_config_from_profile_network( profile.network.as_ref(), )) @@ -244,25 +349,9 @@ pub(crate) fn compile_permission_profile( policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { - let ResolvedPermissionProfileToml { - profile, - inherited_profile_names, - } = resolve_permission_profile(permissions, profile_name)?; - let base_permissions = inherited_profile_names.iter().find_map(|name| { - match name.as_str() { - BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()), - BUILT_IN_WORKSPACE_PROFILE => Some(PermissionProfile::workspace_write()), - _ => None, - } - .map(|profile| profile.to_runtime_permissions()) - }); - let (mut file_system_sandbox_policy, base_network_sandbox_policy) = base_permissions - .unwrap_or_else(|| { - ( - FileSystemSandboxPolicy::restricted(Vec::new()), - NetworkSandboxPolicy::Restricted, - ) - }); + let profile = resolve_permission_profile(permissions, profile_name)?; + let mut file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(Vec::new()); + let base_network_sandbox_policy = NetworkSandboxPolicy::Restricted; if let Some(filesystem) = profile.filesystem.as_ref() { if filesystem.is_empty() && file_system_sandbox_policy.entries.is_empty() { push_warning( @@ -358,7 +447,7 @@ pub(crate) fn compile_permission_profile_workspace_roots( })?; let profile = resolve_permission_profile(permissions, profile_name)?; Ok(compile_workspace_roots( - profile.profile.workspace_roots.as_ref(), + profile.workspace_roots.as_ref(), policy_cwd, )) } @@ -693,6 +782,7 @@ fn parse_special_path(path: &str) -> Option { ":minimal" => Some(FileSystemSpecialPath::Minimal), ":workspace_roots" => Some(FileSystemSpecialPath::project_roots(/*subpath*/ None)), ":tmpdir" => Some(FileSystemSpecialPath::Tmpdir), + ":slash_tmp" => Some(FileSystemSpecialPath::SlashTmp), _ if path.starts_with(':') => { Some(FileSystemSpecialPath::unknown(path, /*subpath*/ None)) } diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 41f4179ba8..88a757181e 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -326,8 +326,7 @@ allow_local_binding = true ) .expect("expected profile should deserialize"); - assert_eq!(resolved.profile, expected_profile); - assert_eq!(resolved.inherited_profile_names, vec!["base".to_string()]); + assert_eq!(resolved, expected_profile); } #[test] diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 7d67ad7859..a2d5fdaa19 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -210,7 +210,7 @@ fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result Self { + let file_system = FileSystemSandboxPolicy::read_only(); Self::Managed { - file_system: ManagedFileSystemPermissions::Restricted { - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }], - glob_scan_max_depth: None, - }, + file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system), network: NetworkSandboxPolicy::Restricted, } } diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index f90859a92b..e5fbc5a0bb 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -358,22 +358,26 @@ pub fn project_roots_glob_pattern(subpath: &Path) -> String { format!("{PROJECT_ROOTS_GLOB_PATTERN_PREFIX}{}", subpath.display()) } +fn read_only_file_system_entries() -> Vec { + vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }] +} + impl Default for FileSystemSandboxPolicy { fn default() -> Self { - Self { - kind: FileSystemSandboxKind::Restricted, - glob_scan_max_depth: None, - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }], - } + Self::read_only() } } impl FileSystemSandboxPolicy { + pub fn read_only() -> Self { + Self::restricted(read_only_file_system_entries()) + } + pub fn unrestricted() -> Self { Self { kind: FileSystemSandboxKind::Unrestricted, From 9e3d5f29e2b6e2bcad3b0384e35e1faaa13e8272 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 2 Jun 2026 11:06:01 -0700 Subject: [PATCH 03/19] [codex] Revert shared BuildBuddy Bazel wrapper (#25909) ## Why PR #25905 intentionally adds a failing `codex-core` unit test, but its [Bazel test on Windows check](https://github.com/openai/codex/actions/runs/26837526950/job/79135369259) passed. That shows the Bazel configuration introduced by #25156 is not behaving as expected, so revert it while the configuration can be investigated separately. ## What changed Revert #25156 in full, restoring the previous Bazel remote configuration, CI scripts, workflows, `rusty_v8` handling, and documentation. This removes the shared BuildBuddy wrapper and its tests. ## Validation Not run locally; this exact revert was prioritized for a fast rollback. --- .bazelrc | 46 ++--- .github/scripts/run-bazel-ci.sh | 126 +++++++----- .github/scripts/run-bazel-query-ci.sh | 56 ++++-- .github/scripts/run_bazel_with_buildbuddy.py | 142 -------------- .github/scripts/rusty_v8_bazel.py | 63 +++--- .../scripts/test_run_bazel_with_buildbuddy.py | 184 ------------------ .github/scripts/test_rusty_v8_bazel.py | 49 ++--- .github/workflows/bazel.yml | 8 +- .github/workflows/rusty-v8-release.yml | 5 +- .github/workflows/v8-canary.yml | 7 +- codex-rs/docs/bazel.md | 114 +---------- justfile | 11 +- scripts/list-bazel-clippy-targets.sh | 24 ++- 13 files changed, 218 insertions(+), 617 deletions(-) delete mode 100755 .github/scripts/run_bazel_with_buildbuddy.py delete mode 100644 .github/scripts/test_run_bazel_with_buildbuddy.py diff --git a/.bazelrc b/.bazelrc index e39a3aff22..6357eb83e4 100644 --- a/.bazelrc +++ b/.bazelrc @@ -38,50 +38,24 @@ common:windows --test_env=WINDIR common --test_env=RUST_MIN_STACK=8388608 # 8 MiB common --test_output=errors -common --nobuild_runfile_links -# These settings tune BuildBuddy/RBE behavior but do not contact a remote -# service unless a `buildbuddy-*` configuration below supplies an endpoint. +common --bes_results_url=https://app.buildbuddy.io/invocation/ +common --bes_backend=grpcs://remote.buildbuddy.io +common --remote_cache=grpcs://remote.buildbuddy.io common --remote_download_toplevel +common --nobuild_runfile_links common --remote_timeout=3600 common --noexperimental_throttle_remote_action_building common --experimental_remote_execution_keepalive common --grpc_keepalive_time=30s - -# Opt-in remote configurations selected by -# `.github/scripts/run_bazel_with_buildbuddy.py`. Plain Bazel commands do not -# contact BuildBuddy unless a user selects one of these configurations. -# Use the generic host for cache, BES, and downloads without remote execution. -common:buildbuddy-generic --bes_backend=grpcs://remote.buildbuddy.io -common:buildbuddy-generic --bes_results_url=https://app.buildbuddy.io/invocation/ -common:buildbuddy-generic --remote_cache=grpcs://remote.buildbuddy.io -common:buildbuddy-generic --experimental_remote_downloader=grpcs://remote.buildbuddy.io - -# Add remote execution on the generic host. -common:buildbuddy-generic-rbe --config=buildbuddy-generic -common:buildbuddy-generic-rbe --config=remote -common:buildbuddy-generic-rbe --remote_executor=grpcs://remote.buildbuddy.io - -# Use the OpenAI tenant for cache, BES, and downloads without remote execution. -common:buildbuddy-openai --bes_backend=grpcs://openai.buildbuddy.io -common:buildbuddy-openai --bes_results_url=https://openai.buildbuddy.io/invocation/ -common:buildbuddy-openai --remote_cache=grpcs://openai.buildbuddy.io -common:buildbuddy-openai --experimental_remote_downloader=grpcs://openai.buildbuddy.io - -# Add remote execution on the OpenAI tenant. -common:buildbuddy-openai-rbe --config=buildbuddy-openai -common:buildbuddy-openai-rbe --config=remote -common:buildbuddy-openai-rbe --remote_executor=grpcs://openai.buildbuddy.io +common --experimental_remote_downloader=grpcs://remote.buildbuddy.io # This limits both in-flight executions and concurrent downloads. Even with high number # of jobs execution will still be limited by CPU cores, so this just pays a bit of # memory in exchange for higher download concurrency. common --jobs=30 -# Shared remote execution policy. The endpoint-bearing `buildbuddy-*-rbe` -# configurations include this group; CI configs override TestRunner below -# when tests must remain local on their runner. -common:remote --strategy=remote common:remote --extra_execution_platforms=//:rbe +common:remote --remote_executor=grpcs://remote.buildbuddy.io common:remote --jobs=800 # TODO(team): Evaluate if this actually helps, zbarsky is not sure, everything seems bottlenecked on `core` either way. # Enable pipelined compilation since we are not bound by local CPU count. @@ -172,11 +146,15 @@ common:ci-windows --repo_contents_cache=D:/a/.cache/bazel-repo-contents-cache # Linux crossbuilds don't work until we untangle the libc constraint mess. common:ci-linux --config=ci-bazel common:ci-linux --build_metadata=TAG_os=linux +common:ci-linux --config=remote +common:ci-linux --strategy=remote common:ci-linux --platforms=//:rbe # On mac, we can run all the build actions remotely but test actions locally. common:ci-macos --config=ci-bazel common:ci-macos --build_metadata=TAG_os=macos +common:ci-macos --config=remote +common:ci-macos --strategy=remote common:ci-macos --strategy=TestRunner=darwin-sandbox,local # On Windows, use Linux remote execution for build actions but keep test actions @@ -184,7 +162,9 @@ common:ci-macos --strategy=TestRunner=darwin-sandbox,local # still run against Windows binaries. common:ci-windows-cross --config=ci-windows common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true +common:ci-windows-cross --config=remote common:ci-windows-cross --host_platform=//:rbe +common:ci-windows-cross --strategy=remote common:ci-windows-cross --strategy=TestRunner=local common:ci-windows-cross --local_test_jobs=4 common:ci-windows-cross --test_env=RUST_TEST_THREADS=1 @@ -200,6 +180,8 @@ common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host common:ci-v8 --config=ci common:ci-v8 --build_metadata=TAG_workflow=v8 common:ci-v8 --build_metadata=TAG_os=linux +common:ci-v8 --config=remote +common:ci-v8 --strategy=remote # Source-built Bazel V8 artifacts use the in-process sandbox by default. This # does not affect Cargo's default prebuilt rusty_v8 path. diff --git a/.github/scripts/run-bazel-ci.sh b/.github/scripts/run-bazel-ci.sh index 89f937a998..f98e4d8cb9 100755 --- a/.github/scripts/run-bazel-ci.sh +++ b/.github/scripts/run-bazel-ci.sh @@ -53,20 +53,11 @@ fi run_bazel() { if [[ "${RUNNER_OS:-}" == "Windows" ]]; then - MSYS2_ARG_CONV_EXCL='*' "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" + MSYS2_ARG_CONV_EXCL='*' bazel "$@" return fi - "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" -} - -run_bazel_with_startup_args() { - if (( ${#bazel_startup_args[@]} > 0 )); then - run_bazel "${bazel_startup_args[@]}" "$@" - return - fi - - run_bazel "$@" + bazel "$@" } ci_config=ci-linux @@ -86,16 +77,23 @@ esac print_bazel_test_log_tails() { local console_log="$1" local testlogs_dir - + local -a bazel_info_cmd=(bazel) local -a bazel_info_args=(info) - if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then - # `bazel info` needs the same CI config as the failed test invocation so - # platform-specific output roots match. On Windows, omitting `ci-windows` - # would point at `local_windows-fastbuild` even when the test ran with the - # MSVC host platform under `local_windows_msvc-fastbuild`. - bazel_info_args+=("--config=${ci_config}") + + if (( ${#bazel_startup_args[@]} > 0 )); then + bazel_info_cmd+=("${bazel_startup_args[@]}") fi + # `bazel info` needs the same CI config as the failed test invocation so + # platform-specific output roots match. On Windows, omitting `ci-windows` + # would point at `local_windows-fastbuild` even when the test ran with the + # MSVC host platform under `local_windows_msvc-fastbuild`. + if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then + bazel_info_args+=( + "--config=${ci_config}" + "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" + ) + fi # Only pass flags that affect Bazel's output-root selection or repository # lookup. Test/build-only flags such as execution logs or remote download # mode can make `bazel info` fail, which would hide the real test log path. @@ -107,7 +105,7 @@ print_bazel_test_log_tails() { esac done - testlogs_dir="$(run_bazel_with_startup_args \ + testlogs_dir="$(run_bazel "${bazel_info_cmd[@]:1}" \ --noexperimental_remote_repo_contents_cache \ "${bazel_info_args[@]}" \ bazel-testlogs 2>/dev/null || echo bazel-testlogs)" @@ -256,9 +254,8 @@ if [[ ${#bazel_args[@]} -eq 0 || ${#bazel_targets[@]} -eq 0 ]]; then fi if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then - # Windows cross-compilation depends on authenticated RBE. Preserve the local - # Windows build shape when credentials are unavailable. - ci_config=ci-windows + # Fork PRs do not receive the BuildBuddy secret needed for the remote + # cross-compile config. Preserve the previous local Windows build shape. windows_msvc_host_platform=1 fi @@ -300,9 +297,9 @@ if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -n "${BUI fi if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then - # The Windows cross-compile config depends on authenticated remote - # execution. When credentials are unavailable, keep the local build shape - # and its lower concurrency cap. + # The Windows cross-compile config depends on remote execution. Fork PRs do + # not receive the BuildBuddy secret, so fall back to the existing local build + # shape and keep its lower concurrency cap. post_config_bazel_args+=(--jobs=8) fi @@ -380,31 +377,70 @@ fi bazel_console_log="$(mktemp)" trap 'rm -f "$bazel_console_log"' EXIT -bazel_run_args=( - "${bazel_args[@]}" -) +bazel_cmd=(bazel) +if (( ${#bazel_startup_args[@]} > 0 )); then + bazel_cmd+=("${bazel_startup_args[@]}") +fi + if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then echo "BuildBuddy API key is available; using remote Bazel configuration." - bazel_run_args+=("--config=${ci_config}") + # Work around Bazel 9 remote repo contents cache / overlay materialization failures + # seen in CI (for example "is not a symlink" or permission errors while + # materializing external repos such as rules_perl). We still use BuildBuddy for + # remote execution/cache; this only disables the startup-level repo contents cache. + bazel_run_args=( + "${bazel_args[@]}" + "--config=${ci_config}" + "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" + ) + if (( ${#post_config_bazel_args[@]} > 0 )); then + bazel_run_args+=("${post_config_bazel_args[@]}") + fi + set +e + run_bazel "${bazel_cmd[@]:1}" \ + --noexperimental_remote_repo_contents_cache \ + "${bazel_run_args[@]}" \ + -- \ + "${bazel_targets[@]}" \ + 2>&1 | tee "$bazel_console_log" + bazel_status=${PIPESTATUS[0]} + set -e else echo "BuildBuddy API key is not available; using local Bazel configuration." + # Keep fork/community PRs on Bazel but disable remote services that are + # configured in .bazelrc and require auth. + # + # Flag docs: + # - Command-line reference: https://bazel.build/reference/command-line-reference + # - Remote caching overview: https://bazel.build/remote/caching + # - Remote execution overview: https://bazel.build/remote/rbe + # - Build Event Protocol overview: https://bazel.build/remote/bep + # + # --noexperimental_remote_repo_contents_cache: + # disable remote repo contents cache enabled in .bazelrc startup options. + # https://bazel.build/reference/command-line-reference#startup_options-flag--experimental_remote_repo_contents_cache + # --remote_cache= and --remote_executor=: + # clear remote cache/execution endpoints configured in .bazelrc. + # https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache + # https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor + bazel_run_args=( + "${bazel_args[@]}" + --remote_cache= + --remote_executor= + ) + if (( ${#post_config_bazel_args[@]} > 0 )); then + bazel_run_args+=("${post_config_bazel_args[@]}") + fi + set +e + run_bazel "${bazel_cmd[@]:1}" \ + --noexperimental_remote_repo_contents_cache \ + "${bazel_run_args[@]}" \ + -- \ + "${bazel_targets[@]}" \ + 2>&1 | tee "$bazel_console_log" + bazel_status=${PIPESTATUS[0]} + set -e fi -if (( ${#post_config_bazel_args[@]} > 0 )); then - bazel_run_args+=("${post_config_bazel_args[@]}") -fi -set +e -# Work around Bazel 9 remote repo contents cache / overlay materialization -# failures seen in CI (for example "is not a symlink" or permission errors -# while materializing external repos such as rules_perl). This only disables -# the startup-level repo contents cache; keyed runs still use BuildBuddy. -run_bazel_with_startup_args \ - --noexperimental_remote_repo_contents_cache \ - "${bazel_run_args[@]}" \ - -- \ - "${bazel_targets[@]}" \ - 2>&1 | tee "$bazel_console_log" -bazel_status=${PIPESTATUS[0]} -set -e if [[ ${bazel_status:-0} -ne 0 ]]; then if [[ $print_failed_bazel_action_summary -eq 1 ]]; then diff --git a/.github/scripts/run-bazel-query-ci.sh b/.github/scripts/run-bazel-query-ci.sh index f5d4f56f49..dd03b67169 100755 --- a/.github/scripts/run-bazel-query-ci.sh +++ b/.github/scripts/run-bazel-query-ci.sh @@ -2,17 +2,48 @@ set -euo pipefail -# Run target-discovery queries with the same startup settings as the main -# build/test invocation so they can reuse the same Bazel server. Queries only -# enumerate labels, so they intentionally do not select CI or remote configs. +# Run Bazel queries with the same CI startup settings as the main build/test +# invocation so target-discovery queries can reuse the same Bazel server. -if [[ $# -lt 2 || "${@: -2:1}" != "--" ]]; then - echo "Usage: $0 [...] -- " >&2 +query_args=() +windows_cross_compile=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --windows-cross-compile) + windows_cross_compile=1 + shift + ;; + --) + shift + break + ;; + *) + query_args+=("$1") + shift + ;; + esac +done + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 [--windows-cross-compile] [...] -- " >&2 exit 1 fi -query_args=("${@:1:$#-2}") -query_expression="${@: -1}" +query_expression="$1" + +ci_config=ci-linux +case "${RUNNER_OS:-}" in + macOS) + ci_config=ci-macos + ;; + Windows) + if [[ $windows_cross_compile -eq 1 ]]; then + ci_config=ci-windows-cross + else + ci_config=ci-windows + fi + ;; +esac bazel_startup_args=() if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then @@ -29,6 +60,12 @@ run_bazel() { } bazel_query_args=(--noexperimental_remote_repo_contents_cache query) +if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then + bazel_query_args+=( + "--config=${ci_config}" + "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" + ) +fi if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then bazel_query_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}") @@ -38,10 +75,7 @@ if [[ -n "${BAZEL_REPOSITORY_CACHE:-}" ]]; then bazel_query_args+=("--repository_cache=${BAZEL_REPOSITORY_CACHE}") fi -if (( ${#query_args[@]} > 0 )); then - bazel_query_args+=("${query_args[@]}") -fi -bazel_query_args+=("$query_expression") +bazel_query_args+=("${query_args[@]}" "$query_expression") if (( ${#bazel_startup_args[@]} > 0 )); then run_bazel "${bazel_startup_args[@]}" "${bazel_query_args[@]}" diff --git a/.github/scripts/run_bazel_with_buildbuddy.py b/.github/scripts/run_bazel_with_buildbuddy.py deleted file mode 100755 index add95a99e5..0000000000 --- a/.github/scripts/run_bazel_with_buildbuddy.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env python3 - -import json -import os -import sys -from collections.abc import Mapping -from collections.abc import Sequence -from pathlib import Path - - -OPENAI_REPOSITORY = "openai/codex" -# Remote configurations select cache/BES/download endpoints. Their -rbe forms -# also select the matching remote executor endpoint. -GENERIC_REMOTE_CONFIG = "buildbuddy-generic" -OPENAI_REMOTE_CONFIG = "buildbuddy-openai" -# These CI configurations require remote build execution. The wrapper supplies -# an RBE configuration, which also includes the common `remote` settings. -REMOTE_EXECUTION_CONFIGS = { - "--config=ci-linux", - "--config=ci-macos", - "--config=ci-v8", - "--config=ci-windows-cross", -} -# Only authenticated workflow runs executing trusted upstream code may use the -# OpenAI BuildBuddy host. A pull request event without proof that its head is -# in the upstream repository fails closed to the generic host. -def is_trusted_upstream_run(env: Mapping[str, str]) -> bool: - # `GITHUB_REPOSITORY` is easy to set locally. Requiring GitHub's workflow - # marker prevents a local command from opting itself into the OpenAI host. - if ( - env.get("GITHUB_ACTIONS") != "true" - or env.get("GITHUB_REPOSITORY") != OPENAI_REPOSITORY - ): - return False - # Non-PR workflow runs in `openai/codex` execute upstream refs, so they are - # trusted. Fork code reaches these workflows only through pull requests. - if env.get("GITHUB_EVENT_NAME") != "pull_request": - return True - - event_path = env.get("GITHUB_EVENT_PATH") - if not event_path: - return False - try: - event = json.loads(Path(event_path).read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return False - - try: - return event["pull_request"]["head"]["repo"]["fork"] is False - except (KeyError, TypeError): - return False - - -def uses_openai_host(env: Mapping[str, str]) -> bool: - return bool(env.get("BUILDBUDDY_API_KEY")) and is_trusted_upstream_run(env) - - -def uses_remote_execution(args: Sequence[str]) -> bool: - try: - separator_idx = args.index("--") - except ValueError: - separator_idx = len(args) - return any(arg in REMOTE_EXECUTION_CONFIGS for arg in args[:separator_idx]) - - -def remote_config(args: Sequence[str], env: Mapping[str, str]) -> str | None: - if not env.get("BUILDBUDDY_API_KEY"): - return None - - config = OPENAI_REMOTE_CONFIG if uses_openai_host(env) else GENERIC_REMOTE_CONFIG - if uses_remote_execution(args): - config += "-rbe" - return config - - -def bazel_args_without_remote_execution(args: Sequence[str]) -> list[str]: - # Remote CI configs require BuildBuddy credentials. Removing them preserves - # the local fallback used for fork pull requests. - try: - separator_idx = args.index("--") - except ValueError: - separator_idx = len(args) - return [ - *(arg for arg in args[:separator_idx] if arg not in REMOTE_EXECUTION_CONFIGS), - *args[separator_idx:], - ] - - -def bazel_args_with_remote_config( - args: Sequence[str], env: Mapping[str, str] -) -> list[str]: - config = remote_config(args, env) - if config is None: - return bazel_args_without_remote_execution(args) - - # `remote_config()` returns a configuration only when this key is present. - api_key = env["BUILDBUDDY_API_KEY"] - remote_args = [ - f"--config={config}", - f"--remote_header=x-buildbuddy-api-key={api_key}", - ] - - # Insert immediately after the Bazel command. This keeps wrapper-added - # options out of positional payloads and lets later CI configs override - # shared RBE defaults such as the Windows cross-compilation exec platforms. - insertion_idx = next( - (idx + 1 for idx, arg in enumerate(args) if not arg.startswith("-")), - len(args), - ) - return [*args[:insertion_idx], *remote_args, *args[insertion_idx:]] - - -def bazel_command(*args: str, env: Mapping[str, str] | None = None) -> list[str]: - env = os.environ if env is None else env - bazel = env.get("CODEX_BAZEL_BIN", "bazel") - return [bazel, *bazel_args_with_remote_config(args, env)] - - -def main() -> None: - config = remote_config(sys.argv[1:], os.environ) - if config is None: - print( - "BuildBuddy key unavailable; using local Bazel configuration.", - file=sys.stderr, - ) - else: - host_description = ( - "OpenAI tenant" if uses_openai_host(os.environ) else "generic" - ) - print( - f"Using {host_description} BuildBuddy configuration: {config}.", - file=sys.stderr, - ) - - command = bazel_command(*sys.argv[1:]) - # Replace the wrapper so Bazel receives signals directly and supplies the - # command exit status; a subprocess parent would have no remaining work. - os.execvp(command[0], command) - - -if __name__ == "__main__": - main() diff --git a/.github/scripts/rusty_v8_bazel.py b/.github/scripts/rusty_v8_bazel.py index 329d3f6c54..2f46daf45e 100644 --- a/.github/scripts/rusty_v8_bazel.py +++ b/.github/scripts/rusty_v8_bazel.py @@ -5,6 +5,7 @@ from __future__ import annotations import argparse import gzip import hashlib +import os import re import shutil import subprocess @@ -12,7 +13,6 @@ import sys import tomllib from pathlib import Path -from run_bazel_with_buildbuddy import bazel_command from rusty_v8_module_bazel import ( RustyV8ChecksumError, check_module_bazel, @@ -29,22 +29,33 @@ SANDBOX_ARTIFACT_PROFILE = "ptrcomp_sandbox_release" ARTIFACT_BAZEL_CONFIGS = ["rusty-v8-upstream-libcxx"] +def bazel_remote_args() -> list[str]: + buildbuddy_api_key = os.environ.get("BUILDBUDDY_API_KEY") + if not buildbuddy_api_key: + return [] + return [f"--remote_header=x-buildbuddy-api-key={buildbuddy_api_key}"] + + def bazel_execroot() -> Path: - output = subprocess.check_output( - bazel_command("info", "execution_root"), + result = subprocess.run( + ["bazel", "info", "execution_root"], cwd=ROOT, + check=True, + capture_output=True, text=True, ) - return Path(output.strip()) + return Path(result.stdout.strip()) def bazel_output_base() -> Path: - output = subprocess.check_output( - bazel_command("info", "output_base"), + result = subprocess.run( + ["bazel", "info", "output_base"], cwd=ROOT, + check=True, + capture_output=True, text=True, ) - return Path(output.strip()) + return Path(result.stdout.strip()) def bazel_output_path(path: str) -> Path: @@ -61,22 +72,24 @@ def bazel_output_files( ) -> list[Path]: expression = "set(" + " ".join(labels) + ")" bazel_configs = bazel_configs or [] - output = subprocess.check_output( - bazel_command( + result = subprocess.run( + [ + "bazel", "cquery", "-c", compilation_mode, f"--platforms=@llvm//platforms:{platform}", *[f"--config={config}" for config in bazel_configs], + *bazel_remote_args(), "--output=files", expression, - ), + ], cwd=ROOT, + check=True, + capture_output=True, text=True, ) - return [ - bazel_output_path(line.strip()) for line in output.splitlines() if line.strip() - ] + return [bazel_output_path(line.strip()) for line in result.stdout.splitlines() if line.strip()] def bazel_build( @@ -89,15 +102,17 @@ def bazel_build( bazel_configs = bazel_configs or [] download_args = ["--remote_download_toplevel"] if download_toplevel else [] subprocess.run( - bazel_command( + [ + "bazel", "build", "-c", compilation_mode, f"--platforms=@llvm//platforms:{platform}", *[f"--config={config}" for config in bazel_configs], + *bazel_remote_args(), *download_args, *labels, - ), + ], cwd=ROOT, check=True, ) @@ -157,7 +172,7 @@ def resolved_v8_crate_version() -> str: matches = sorted( set( re.findall( - r"https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate", + r'https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate', module_bazel, ) ) @@ -219,17 +234,13 @@ def stage_artifacts( output_dir: Path, sandbox: bool, ) -> None: - missing_paths = [ - str(path) for path in [lib_path, binding_path] if not path.exists() - ] + missing_paths = [str(path) for path in [lib_path, binding_path] if not path.exists()] if missing_paths: raise SystemExit(f"missing release outputs for {target}: {missing_paths}") output_dir.mkdir(parents=True, exist_ok=True) artifact_profile = SANDBOX_ARTIFACT_PROFILE if sandbox else RELEASE_ARTIFACT_PROFILE - staged_library = output_dir / staged_archive_name( - target, lib_path, artifact_profile - ) + staged_library = output_dir / staged_archive_name(target, lib_path, artifact_profile) staged_binding = output_dir / staged_binding_name(target, artifact_profile) with lib_path.open("rb") as src, staged_library.open("wb") as dst: @@ -259,9 +270,7 @@ def stage_artifacts( def upstream_release_pair_paths(source_root: Path, target: str) -> tuple[Path, Path]: - lib_name = ( - "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a" - ) + lib_name = "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a" gn_out = source_root / "target" / target / "release" / "gn_out" return gn_out / "obj" / lib_name, gn_out / "src_binding.rs" @@ -329,9 +338,7 @@ def parse_args() -> argparse.Namespace: stage_upstream_release_pair_parser = subparsers.add_parser( "stage-upstream-release-pair" ) - stage_upstream_release_pair_parser.add_argument( - "--source-root", type=Path, required=True - ) + stage_upstream_release_pair_parser.add_argument("--source-root", type=Path, required=True) stage_upstream_release_pair_parser.add_argument("--target", required=True) stage_upstream_release_pair_parser.add_argument("--output-dir", required=True) stage_upstream_release_pair_parser.add_argument("--sandbox", action="store_true") diff --git a/.github/scripts/test_run_bazel_with_buildbuddy.py b/.github/scripts/test_run_bazel_with_buildbuddy.py deleted file mode 100644 index bab4ad5cce..0000000000 --- a/.github/scripts/test_run_bazel_with_buildbuddy.py +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env python3 - -import json -import unittest -from pathlib import Path -from tempfile import TemporaryDirectory - -import run_bazel_with_buildbuddy - - -class RunBazelWithBuildBuddyTest(unittest.TestCase): - def github_env( - self, - temp_dir: str, - *, - repository: str = "openai/codex", - fork: bool = False, - event_name: str = "pull_request", - ) -> dict[str, str]: - event_path = Path(temp_dir) / "event.json" - event_path.write_text( - json.dumps({"pull_request": {"head": {"repo": {"fork": fork}}}}), - encoding="utf-8", - ) - return { - "BUILDBUDDY_API_KEY": "token", - "GITHUB_ACTIONS": "true", - "GITHUB_EVENT_NAME": event_name, - "GITHUB_EVENT_PATH": str(event_path), - "GITHUB_REPOSITORY": repository, - } - - def test_keyless_invocation_drops_remote_ci_configuration(self) -> None: - self.assertIsNone( - run_bazel_with_buildbuddy.remote_config( - ["build", "--config=ci-linux", "//codex-rs/cli:codex"], - {}, - ) - ) - self.assertEqual( - run_bazel_with_buildbuddy.bazel_args_with_remote_config( - ["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"], - {}, - ), - ["build", "--", "//codex-rs/cli:codex"], - ) - - def test_program_arguments_after_separator_do_not_select_or_lose_rbe(self) -> None: - args = ["run", "//codex-rs/cli:codex", "--", "--config=remote"] - - self.assertEqual( - run_bazel_with_buildbuddy.bazel_args_with_remote_config(args, {}), - args, - ) - self.assertEqual( - run_bazel_with_buildbuddy.remote_config( - args, {"BUILDBUDDY_API_KEY": "fork-token"} - ), - "buildbuddy-generic", - ) - - def test_upstream_push_selects_openai_rbe_before_target_separator(self) -> None: - with TemporaryDirectory() as temp_dir: - env = self.github_env(temp_dir, event_name="push") - - self.assertEqual( - run_bazel_with_buildbuddy.bazel_args_with_remote_config( - ["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"], - env, - ), - [ - "build", - "--config=buildbuddy-openai-rbe", - "--remote_header=x-buildbuddy-api-key=token", - "--config=ci-linux", - "--", - "//codex-rs/cli:codex", - ], - ) - - def test_windows_cross_ci_configuration_follows_remote_configuration(self) -> None: - env = {"BUILDBUDDY_API_KEY": "fork-token"} - - self.assertEqual( - run_bazel_with_buildbuddy.bazel_args_with_remote_config( - ["build", "--config=ci-windows-cross", "//codex-rs/cli:codex"], - env, - ), - [ - "build", - "--config=buildbuddy-generic-rbe", - "--remote_header=x-buildbuddy-api-key=fork-token", - "--config=ci-windows-cross", - "//codex-rs/cli:codex", - ], - ) - - def test_query_remote_configuration_is_inserted_before_expression(self) -> None: - expression = 'kind("rust_library rule", //codex-rs/...)' - env = {"BUILDBUDDY_API_KEY": "fork-token"} - - for command in ("query", "cquery", "aquery"): - with self.subTest(command=command): - self.assertEqual( - run_bazel_with_buildbuddy.bazel_args_with_remote_config( - [ - command, - "--config=ci-windows-cross", - "--output=label", - expression, - ], - env, - ), - [ - command, - "--config=buildbuddy-generic-rbe", - "--remote_header=x-buildbuddy-api-key=fork-token", - "--config=ci-windows-cross", - "--output=label", - expression, - ], - ) - - def test_same_repository_pull_request_selects_openai_host(self) -> None: - with TemporaryDirectory() as temp_dir: - self.assertEqual( - run_bazel_with_buildbuddy.remote_config( - ["build", "--config=ci-v8"], self.github_env(temp_dir) - ), - "buildbuddy-openai-rbe", - ) - - def test_fork_pull_request_cannot_select_openai_host(self) -> None: - with TemporaryDirectory() as temp_dir: - env = self.github_env(temp_dir, fork=True) - - self.assertEqual( - run_bazel_with_buildbuddy.remote_config( - ["build", "--config=ci-v8"], env - ), - "buildbuddy-generic-rbe", - ) - - def test_run_in_fork_repository_cannot_select_openai_host(self) -> None: - with TemporaryDirectory() as temp_dir: - env = self.github_env(temp_dir, repository="contributor/codex") - - self.assertEqual( - run_bazel_with_buildbuddy.remote_config( - ["build", "--config=ci-v8"], env - ), - "buildbuddy-generic-rbe", - ) - - def test_pull_request_without_readable_event_payload_fails_closed(self) -> None: - for event_path in (None, "missing-event.json"): - env = { - "BUILDBUDDY_API_KEY": "token", - "GITHUB_ACTIONS": "true", - "GITHUB_EVENT_NAME": "pull_request", - "GITHUB_REPOSITORY": "openai/codex", - } - if event_path is not None: - env["GITHUB_EVENT_PATH"] = event_path - - with self.subTest(event_path=event_path): - self.assertEqual( - run_bazel_with_buildbuddy.remote_config(["build"], env), - "buildbuddy-generic", - ) - - def test_bazel_command_uses_configured_binary_locally(self) -> None: - self.assertEqual( - run_bazel_with_buildbuddy.bazel_command( - "info", - "execution_root", - env={"CODEX_BAZEL_BIN": "fake-bazel"}, - ), - ["fake-bazel", "info", "execution_root"], - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/.github/scripts/test_rusty_v8_bazel.py b/.github/scripts/test_rusty_v8_bazel.py index 0b5c03f436..19690dbece 100644 --- a/.github/scripts/test_rusty_v8_bazel.py +++ b/.github/scripts/test_rusty_v8_bazel.py @@ -88,49 +88,24 @@ class RustyV8BazelTest(unittest.TestCase): ), ) - def test_bazel_commands_use_shared_buildbuddy_remote_config_library(self) -> None: - with patch.dict(environ, {}, clear=True): + def test_bazel_remote_args_include_buildbuddy_header_when_present(self) -> None: + with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=False): self.assertEqual( - [ - "bazel", - "build", - "//third_party/v8:release", - ], - rusty_v8_bazel.bazel_command( - "build", - "--config=ci-v8", - "//third_party/v8:release", - ), - ) - with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=True): - self.assertEqual( - [ - "bazel", - "build", - "--config=buildbuddy-generic-rbe", - "--remote_header=x-buildbuddy-api-key=token", - "--config=ci-v8", - "//third_party/v8:release", - ], - rusty_v8_bazel.bazel_command( - "build", - "--config=ci-v8", - "//third_party/v8:release", - ), + ["--remote_header=x-buildbuddy-api-key=token"], + rusty_v8_bazel.bazel_remote_args(), ) - def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts( - self, - ) -> None: + with patch.dict(environ, {}, clear=True): + self.assertEqual([], rusty_v8_bazel.bazel_remote_args()) + + def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts(self) -> None: self.assertEqual( "//third_party/v8:rusty_v8_release_pair_x86_64_unknown_linux_musl", rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl"), ) self.assertEqual( "//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_unknown_linux_musl", - rusty_v8_bazel.release_pair_label( - "x86_64-unknown-linux-musl", sandbox=True - ), + rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl", sandbox=True), ) self.assertEqual( "//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_apple_darwin", @@ -230,7 +205,11 @@ class RustyV8BazelTest(unittest.TestCase): with TemporaryDirectory() as source_dir, TemporaryDirectory() as output_dir: source_root = Path(source_dir) gn_out = ( - source_root / "target" / "x86_64-pc-windows-msvc" / "release" / "gn_out" + source_root + / "target" + / "x86_64-pc-windows-msvc" + / "release" + / "gn_out" ) (gn_out / "obj").mkdir(parents=True) (gn_out / "obj" / "rusty_v8.lib").write_bytes(b"archive") diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index e2782b4904..153ace0fc9 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -15,7 +15,6 @@ concurrency: # See https://docs.github.com/en/actions/using-jobs/using-concurrency and https://docs.github.com/en/actions/learn-github-actions/contexts for more info. group: concurrency-group::${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}${{ github.ref_name == 'main' && format('::{0}', github.run_id) || ''}} cancel-in-progress: ${{ github.ref_name != 'main' }} - jobs: test: # PRs use the sharded Windows cross-compiled test jobs below. Post-merge @@ -56,17 +55,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' - with: - tool: just - - name: Check rusty_v8 MODULE.bazel checksums if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' shell: bash run: | python3 .github/scripts/rusty_v8_bazel.py check-module-bazel - just test-github-scripts + python3 -m unittest discover -s .github/scripts -p test_rusty_v8_bazel.py - name: Prepare Bazel CI id: prepare_bazel diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index d6fb73e96d..4b5320dcb9 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -191,10 +191,11 @@ jobs: bazel_args+=(--config=v8-release-compat) fi - ./.github/scripts/run_bazel_with_buildbuddy.py \ + bazel \ --noexperimental_remote_repo_contents_cache \ "${bazel_args[@]}" \ - "--config=${{ matrix.bazel_config }}" + "--config=${{ matrix.bazel_config }}" \ + "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - name: Stage release pair env: diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 0ad9f850d1..71ce5d7865 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -5,7 +5,6 @@ on: paths: - ".bazelrc" - ".github/actions/setup-bazel-ci/**" - - ".github/scripts/run_bazel_with_buildbuddy.py" - ".github/scripts/rusty_v8_bazel.py" - ".github/scripts/rusty_v8_module_bazel.py" - ".github/workflows/rusty-v8-release.yml" @@ -24,7 +23,6 @@ on: paths: - ".bazelrc" - ".github/actions/setup-bazel-ci/**" - - ".github/scripts/run_bazel_with_buildbuddy.py" - ".github/scripts/rusty_v8_bazel.py" - ".github/scripts/rusty_v8_module_bazel.py" - ".github/workflows/rusty-v8-release.yml" @@ -205,10 +203,11 @@ jobs: bazel_args+=(--config=v8-release-compat) fi - ./.github/scripts/run_bazel_with_buildbuddy.py \ + bazel \ --noexperimental_remote_repo_contents_cache \ "${bazel_args[@]}" \ - "--config=${{ matrix.bazel_config }}" + "--config=${{ matrix.bazel_config }}" \ + "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - name: Stage release pair env: diff --git a/codex-rs/docs/bazel.md b/codex-rs/docs/bazel.md index 085c15992f..a124688a24 100644 --- a/codex-rs/docs/bazel.md +++ b/codex-rs/docs/bazel.md @@ -4,7 +4,7 @@ This repository uses Bazel to build the Rust workspace under `codex-rs`. Cargo remains the source of truth for crates and features, while Bazel provides hermetic builds, toolchains, and cross-platform artifacts. -As of 6/1/2026, this setup is still experimental as we stabilize it. +As of 1/9/2026, this setup is still experimental as we stabilize it. ## High-level layout @@ -20,118 +20,6 @@ As of 6/1/2026, this setup is still experimental as we stabilize it. makes some adjustments if the crate needs additional compile-time or runtime data, or other customizations. -## Running Bazel locally - -The repository root `justfile` exposes the common Bazel entry points: - -```bash -just bazel-test -just bazel-clippy -``` - -Ordinary local `bazel` and `just` invocations run locally. BuildBuddy cache, -build event upload, downloads, and remote execution are opt-in configurations. - -## BuildBuddy - -Codex uses BuildBuddy for a shared Bazel cache and remoted builds and tests. To use it -to speed up your builds and tests you'll need to provide an API key and select a -configuration. - -### BuildBuddy API key - -If you're an OpenAI employee, log in to https://openai.buildbuddy.io and use Google sign-in. - -Create a BuildBuddy API key as described in BuildBuddy's [Authentication Guide][bb-auth-guide], -then add it to `~/.bazelrc`: - -```bazelrc -# Local machine only; this file contains a BuildBuddy credential. -common --remote_header=x-buildbuddy-api-key= -``` - -Keeping the credential outside the workspace reduces the risk of accidentally -committing it. - -If you need different API keys for different projects, put the API key in -`%workspace%/user.bazelrc` instead. The checked-in `.bazelrc` optionally imports -that file, and `.gitignore` excludes it. Do not commit or share a file containing -the credential. - -[bb-auth-guide]: https://www.buildbuddy.io/docs/guide-auth/#managing-keys - -### Selecting a remote build configuration - -OpenAI employees should default to the OpenAI host with remote execution unless -they have a reason to choose another configuration. Add the following configuration -to `%workspace%/user.bazelrc`: - -```bazelrc -common --config=buildbuddy-openai-rbe -``` - -OpenAI employees who don't want remote execution can use `buildbuddy-openai`. External users -should use `buildbuddy-generic-rbe` or `buildbuddy-generic`. See below for details on these -configurations. - -### All remote configurations - -GitHub Actions routes Bazel build and output-resolution commands through -`.github/scripts/run_bazel_with_buildbuddy.py`. Higher-level helpers such as -`.github/scripts/run-bazel-ci.sh` and `.github/scripts/rusty_v8_bazel.py` -delegate remote configuration selection to that wrapper. The wrapper reads the -GitHub Actions repository and event payload rather than relying on workflow -files to duplicate tenant-selection logic. - -Loading-phase target-discovery `bazel query` commands run locally because they -only enumerate labels and do not need remote caches or execution. - -The `Cache/BES` host is also used for remote downloads. - -| Invocation/config | Key Required | Cache/BES | Build exec | Test exec | -| --- | --- | --- | --- | --- | -| `bazel ...` | No | None | Local | Local | -| `bazel ... --config=buildbuddy-generic` | Yes | `remote.buildbuddy.io` | Local | Local | -| `bazel ... --config=buildbuddy-generic-rbe` | Yes | `remote.buildbuddy.io` | Remote | Remote | -| `bazel ... --config=buildbuddy-openai` | Yes | `openai.buildbuddy.io` | Local | Local | -| `bazel ... --config=buildbuddy-openai-rbe` | Yes | `openai.buildbuddy.io` | Remote | Remote | - -Without an API key, the wrapper removes remote CI configurations and runs -locally. With a key, workflows choose the host as follows: - -| Run | Key | Uses OpenAI BuildBuddy Host | -| --- | --- | --- | -| Push to `main` in `openai/codex` | Yes | Yes | -| `workflow_dispatch` in `openai/codex` | Yes | Yes | -| Same-repository pull request in `openai/codex` | Yes | Yes | -| Fork pull request into `openai/codex` | No | No; local | -| Push or `workflow_dispatch` in a fork with a key | Yes | No; generic host | -| Pull request run in a fork repository with a key | Yes | No; generic host | - -CI configurations determine whether builds and tests execute remotely: - -| CI config | Remote config | Build exec | Test exec | -| --- | --- | --- | --- | -| `ci-linux` | `*-rbe` | Remote host | Remote host | -| `ci-v8` | `*-rbe` | Remote host | Remote host | -| `ci-macos` | `*-rbe` | Remote host | Local | -| `ci-windows-cross` | `*-rbe` | Remote host | Local | -| `ci-windows` | non-RBE | Local | Local | -| Keyless CI fallback | none | Local | Local | - -To exercise the generic remote configuration with your key: - -```bash -BUILDBUDDY_API_KEY=... GITHUB_REPOSITORY=my-fork/codex \ - ./.github/scripts/run_bazel_with_buildbuddy.py \ - build --config=ci-linux //codex-rs/cli:codex -``` - -The wrapper selects the OpenAI host only inside GitHub Actions for a trusted -run in `openai/codex`. A missing or malformed pull request event -payload fails closed to the generic host. For local OpenAI host access, use -the `user.bazelrc` configuration above. - ## Evolving the setup When you add or change Rust dependencies, update the Cargo.toml/Cargo.lock as normal. diff --git a/justfile b/justfile index fe7e7349b3..34b5115ee0 100644 --- a/justfile +++ b/justfile @@ -83,12 +83,6 @@ test *args: $env:RUST_MIN_STACK = "{{ rust_min_stack }}"; cargo nextest run --no-fail-fast @($args | Select-Object -Skip 1) just bench-smoke -# Run from the repository root so scripts that resolve paths from `cwd` see -# the same layout they use in GitHub Actions. -[no-cd] -test-github-scripts: - {{ python }} -m unittest discover -s {{ justfile_directory() }}/.github/scripts -p 'test_*.py' - # Run explicit workspace benchmark targets. bench *args: cargo bench --workspace --bench '*' {args} @@ -135,8 +129,11 @@ bazel-clippy: bazel-argument-comment-lint: bazel build --config=argument-comment-lint -- $({{ justfile_directory() }}/tools/argument-comment-lint/list-bazel-targets.sh) +bazel-remote-test: + bazel test --test_tag_filters=-argument-comment-lint //... --config=remote --platforms=//:rbe --keep_going + build-for-release: - bazel build //codex-rs/cli:release_binaries + bazel build //codex-rs/cli:release_binaries --config=remote # Run the MCP server mcp-server-run *args: diff --git a/scripts/list-bazel-clippy-targets.sh b/scripts/list-bazel-clippy-targets.sh index d12a256d00..b76fc2a830 100755 --- a/scripts/list-bazel-clippy-targets.sh +++ b/scripts/list-bazel-clippy-targets.sh @@ -20,13 +20,23 @@ while [[ $# -gt 0 ]]; do done # Resolve the dynamic targets before printing anything so callers do not -# continue with a partial list if `bazel query` fails. Target discovery is -# local on all platforms. -manual_rust_test_targets="$( - ./.github/scripts/run-bazel-query-ci.sh \ - --output=label \ - -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' -)" +# continue with a partial list if `bazel query` fails. Reuse the same CI Bazel +# server settings as the subsequent build so Windows jobs do not cold-start a +# second Bazel server just for target discovery. +if [[ $windows_cross_compile -eq 1 ]]; then + manual_rust_test_targets="$( + ./.github/scripts/run-bazel-query-ci.sh \ + --windows-cross-compile \ + --output=label \ + -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' + )" +else + manual_rust_test_targets="$( + ./.github/scripts/run-bazel-query-ci.sh \ + --output=label \ + -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' + )" +fi if [[ "${RUNNER_OS:-}" != "Windows" ]]; then # Non-Windows clippy jobs lint the native test binaries; the # Windows-cross binaries exist only for the fast Windows test leg. From 503ec190a88da1dfa43294a98ef5530769095778 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 2 Jun 2026 20:16:57 +0200 Subject: [PATCH 04/19] Key request-permission grants by environment (#25850) ## Stack 1. This PR (#25850) - Key request-permission grants by environment: stores and applies sticky permission grants per environment id. 2. #25858 - Add `environmentId` to `request_permissions`: lets the model target a selected environment and resolves relative permission paths against it. 3. #25862 - Propagate permission approval environment id: carries the selected environment id through approval events, app-server requests, TUI prompts, and delegate forwarding. 4. #25867 - Add remote request permissions integration coverage: verifies the selected remote environment across request, approval, grant reuse, and exec. #25858, #25862, and #25867 are stacked on this PR and should be reviewed after it. ## Why Multi-environment CCA turns can attach both local and remote executors, but request-permission grants were still effectively cwd-only. Pending permission requests tracked a cwd, while stored turn/session grants had no environment identity, so sticky grants could be reused through the wrong executor context. This makes the first permission-grant step environment-aware without changing the external `request_permissions` payload shape: omitted environment targeting remains bound to the primary turn environment. ## What Changed - Store turn- and session-scoped request-permission grants by `environment_id`. - Keep the selected `TurnEnvironmentSelection` with pending `request_permissions` calls so approval responses normalize and record grants against the same environment. - Resolve relative `request_permissions` file paths against the primary turn environment cwd instead of deprecated `turn.cwd`. - Apply sticky grants in `shell`, `exec_command`, and `apply_patch` by selected environment id while still using the actual tool cwd for cwd-relative permission materialization. - Update Guardian and request-permissions coverage for the environment-keyed grant behavior. ## Testing Not run locally. Added or updated focused coverage for: - `request_permission_grants_are_environment_keyed` - `request_permissions_tool_resolves_relative_paths_against_primary_environment` - related Guardian/request-permissions sticky grant tests --- codex-rs/core/src/session/mod.rs | 75 +++++-- codex-rs/core/src/session/tests.rs | 195 +++++++++++++++++- .../core/src/session/tests/guardian_tests.rs | 27 ++- codex-rs/core/src/state/session.rs | 31 ++- codex-rs/core/src/state/turn.rs | 34 ++- .../core/src/tools/handlers/apply_patch.rs | 31 ++- codex-rs/core/src/tools/handlers/mod.rs | 7 +- .../src/tools/handlers/request_permissions.rs | 8 +- codex-rs/core/src/tools/handlers/shell.rs | 4 +- .../handlers/unified_exec/exec_command.rs | 1 + 10 files changed, 356 insertions(+), 57 deletions(-) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index b3dcf504ee..d8f660e2a1 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2107,12 +2107,18 @@ impl Session { args: RequestPermissionsArgs, cancellation_token: CancellationToken, ) -> Option { - self.request_permissions_for_cwd( + let Some(turn_environment) = turn_context.environments.primary() else { + return Some(RequestPermissionsResponse { + permissions: RequestPermissionProfile::default(), + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }); + }; + self.request_permissions_for_environment( turn_context, call_id, args, - #[allow(deprecated)] - turn_context.cwd.clone(), + turn_environment.selection(), cancellation_token, ) .await @@ -2122,12 +2128,12 @@ impl Session { clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" )] - pub(crate) async fn request_permissions_for_cwd( + pub(crate) async fn request_permissions_for_environment( self: &Arc, turn_context: &Arc, call_id: String, args: RequestPermissionsArgs, - cwd: AbsolutePathBuf, + environment: TurnEnvironmentSelection, cancellation_token: CancellationToken, ) -> Option { match turn_context.as_ref().approval_policy.value() { @@ -2221,10 +2227,11 @@ impl Session { let response = Self::normalize_request_permissions_response( requested_permissions, response, - cwd.as_path(), + environment.cwd.as_path(), ); self.record_granted_request_permissions_for_turn( &response, + &environment.environment_id, originating_turn_state.as_ref(), ) .await; @@ -2242,7 +2249,7 @@ impl Session { PendingRequestPermissions { tx_response, requested_permissions: requested_permissions.clone(), - cwd: cwd.clone(), + environment: environment.clone(), }, ) } @@ -2259,7 +2266,7 @@ impl Session { started_at_ms: now_unix_timestamp_ms(), reason: args.reason, permissions: requested_permissions, - cwd: Some(cwd), + cwd: Some(environment.cwd), }); self.send_event(turn_context.as_ref(), event).await; tokio::select! { @@ -2276,6 +2283,33 @@ impl Session { } } + pub(crate) async fn request_permissions_for_cwd( + self: &Arc, + turn_context: &Arc, + call_id: String, + args: RequestPermissionsArgs, + cwd: AbsolutePathBuf, + cancellation_token: CancellationToken, + ) -> Option { + let Some(primary_environment) = turn_context.environments.primary() else { + return Some(RequestPermissionsResponse { + permissions: RequestPermissionProfile::default(), + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }); + }; + let mut environment = primary_environment.selection(); + environment.cwd = cwd; + self.request_permissions_for_environment( + turn_context, + call_id, + args, + environment, + cancellation_token, + ) + .await + } + #[expect( clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" @@ -2370,10 +2404,11 @@ impl Session { let response = Self::normalize_request_permissions_response( entry.requested_permissions, response, - entry.cwd.as_path(), + entry.environment.cwd.as_path(), ); self.record_granted_request_permissions_for_turn( &response, + &entry.environment.environment_id, originating_turn_state.as_ref(), ) .await; @@ -2417,6 +2452,7 @@ impl Session { async fn record_granted_request_permissions_for_turn( &self, response: &RequestPermissionsResponse, + environment_id: &str, originating_turn_state: Option<&Arc>>, ) { if response.permissions.is_empty() { @@ -2428,7 +2464,7 @@ impl Session { let mut ts = turn_state.lock().await; let permissions: AdditionalPermissionProfile = response.permissions.clone().into(); - ts.record_granted_permissions(permissions); + ts.record_granted_permissions(environment_id, permissions); if response.strict_auto_review { ts.enable_strict_auto_review(); } @@ -2436,7 +2472,10 @@ impl Session { } PermissionGrantScope::Session => { let mut state = self.state.lock().await; - state.record_granted_permissions(response.permissions.clone().into()); + state.record_granted_permissions( + environment_id, + response.permissions.clone().into(), + ); } } } @@ -2445,11 +2484,14 @@ impl Session { clippy::await_holding_invalid_type, reason = "active turn reads must stay consistent with the matching turn state" )] - pub(crate) async fn granted_turn_permissions(&self) -> Option { + pub(crate) async fn granted_turn_permissions( + &self, + environment_id: &str, + ) -> Option { let active = self.active_turn.lock().await; let active = active.as_ref()?; let ts = active.turn_state.lock().await; - ts.granted_permissions() + ts.granted_permissions(environment_id) } #[expect( @@ -2465,9 +2507,12 @@ impl Session { ts.strict_auto_review_enabled() } - pub(crate) async fn granted_session_permissions(&self) -> Option { + pub(crate) async fn granted_session_permissions( + &self, + environment_id: &str, + ) -> Option { let state = self.state.lock().await; - state.granted_permissions() + state.granted_permissions(environment_id) } #[expect( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index d4b4942a88..260e91c092 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -73,6 +73,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::ExecCommandHandler; +use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::UpdateGoalHandler; use crate::tools::registry::ToolExecutor; @@ -5155,7 +5156,12 @@ async fn notify_request_permissions_response_ignores_unmatched_call_id() { ) .await; - assert_eq!(session.granted_turn_permissions().await, None); + assert_eq!( + session + .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, + None + ); } #[tokio::test] @@ -5182,16 +5188,84 @@ async fn record_granted_request_permissions_for_turn_uses_originating_turn() { scope: PermissionGrantScope::Turn, strict_auto_review: false, }, + codex_exec_server::LOCAL_ENVIRONMENT_ID, Some(&originating_turn_state), ) .await; assert_eq!( - originating_turn_state.lock().await.granted_permissions(), + originating_turn_state + .lock() + .await + .granted_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID), Some(requested_permissions.into()) ); - assert_eq!(current_turn_state.lock().await.granted_permissions(), None); - assert_eq!(session.granted_turn_permissions().await, None); + assert_eq!( + current_turn_state + .lock() + .await + .granted_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID), + None + ); + assert_eq!( + session + .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, + None + ); +} + +#[tokio::test] +async fn request_permission_grants_are_environment_keyed() { + let (session, _turn_context) = make_session_and_context().await; + let originating_active_turn = ActiveTurn::default(); + let originating_turn_state = Arc::clone(&originating_active_turn.turn_state); + *session.active_turn.lock().await = Some(originating_active_turn); + + let requested_permissions = RequestPermissionProfile { + network: Some(codex_protocol::models::NetworkPermissions { + enabled: Some(true), + }), + ..RequestPermissionProfile::default() + }; + session + .record_granted_request_permissions_for_turn( + &codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: requested_permissions.clone(), + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }, + "remote", + Some(&originating_turn_state), + ) + .await; + + { + let turn_state = originating_turn_state.lock().await; + assert_eq!( + turn_state.granted_permissions("remote"), + Some(requested_permissions.clone().into()) + ); + assert_eq!(turn_state.granted_permissions("local"), None); + } + + session + .record_granted_request_permissions_for_turn( + &codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: requested_permissions.clone(), + scope: PermissionGrantScope::Session, + strict_auto_review: false, + }, + "remote", + /*originating_turn_state*/ None, + ) + .await; + + assert_eq!( + session.granted_session_permissions("remote").await, + Some(requested_permissions.into()) + ); + assert_eq!(session.granted_session_permissions("local").await, None); } #[tokio::test] @@ -5214,6 +5288,7 @@ async fn enable_strict_auto_review_for_turn_uses_originating_turn() { scope: PermissionGrantScope::Turn, strict_auto_review: true, }, + codex_exec_server::LOCAL_ENVIRONMENT_ID, Some(&originating_turn_state), ) .await; @@ -5333,6 +5408,107 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() assert_eq!(response, Some(expected_response)); } +#[tokio::test] +async fn request_permissions_tool_resolves_relative_paths_against_primary_environment() { + let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + let environment_cwd = { + #[allow(deprecated)] + let legacy_cwd = turn_context.cwd.clone(); + legacy_cwd.join("request-permissions-environment") + }; + std::fs::create_dir_all(environment_cwd.as_path()).expect("create environment cwd"); + let turn_context_mut = Arc::get_mut(&mut turn_context).expect("single thread settings ref"); + turn_context_mut + .approval_policy + .set(AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + })) + .expect("test setup should allow updating approval policy"); + turn_context_mut.environments.turn_environments[0].cwd = environment_cwd.clone(); + + let call_id = "call-1".to_string(); + let handler = RequestPermissionsHandler; + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let handle = tokio::spawn({ + let session = Arc::clone(&session); + let turn_context = Arc::clone(&turn_context); + let tracker = Arc::clone(&tracker); + let call_id = call_id.clone(); + async move { + handler + .handle(ToolInvocation { + session, + turn: turn_context, + cancellation_token: CancellationToken::new(), + tracker, + call_id, + tool_name: codex_tools::ToolName::plain("request_permissions"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: json!({ + "reason": "need write", + "permissions": { + "file_system": { + "entries": [{ + "path": { + "type": "path", + "path": "relative.txt", + }, + "access": "write", + }], + }, + }, + }) + .to_string(), + }, + }) + .await + } + }); + + let request_event = tokio::time::timeout(StdDuration::from_secs(1), rx.recv()) + .await + .expect("request_permissions event timed out") + .expect("request_permissions event missing"); + let EventMsg::RequestPermissions(request) = request_event.msg else { + panic!("expected request_permissions event"); + }; + let expected_permissions = RequestPermissionProfile { + file_system: Some(FileSystemPermissions { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: environment_cwd.join("relative.txt"), + }, + access: FileSystemAccessMode::Write, + }], + glob_scan_max_depth: None, + }), + ..Default::default() + }; + assert_eq!(request.permissions, expected_permissions); + + session + .notify_request_permissions_response( + &request.call_id, + codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: request.permissions, + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }, + ) + .await; + tokio::time::timeout(StdDuration::from_secs(1), handle) + .await + .expect("request_permissions handler timed out") + .expect("request_permissions handler join error") + .expect("request_permissions handler should succeed"); +} + #[tokio::test] async fn request_permissions_response_materializes_session_cwd_grants_before_recording() { let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; @@ -5425,7 +5601,9 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec assert_eq!(response, Some(expected_response)); assert_eq!( - session.granted_session_permissions().await, + session + .granted_session_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, Some(expected_permissions.into()) ); } @@ -10450,7 +10628,12 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { ); pretty_assertions::assert_eq!(output, expected); - pretty_assertions::assert_eq!(session.granted_turn_permissions().await, None); + pretty_assertions::assert_eq!( + session + .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, + None + ); // The rejection should not poison the non-escalated path for the same // command. Force DangerFullAccess so this check stays focused on approval diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 2977e6bdf5..e8fccd7d72 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -148,7 +148,9 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() { }) ); assert_eq!( - session.granted_turn_permissions().await, + session + .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, Some(requested_permissions.into()) ); @@ -246,7 +248,12 @@ async fn request_permissions_guardian_review_stops_when_cancelled() { .expect("request_permissions should stop when cancelled") .expect("request_permissions task should not panic"); assert_eq!(response, None); - assert_eq!(session.granted_turn_permissions().await, None); + assert_eq!( + session + .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, + None + ); } #[tokio::test] @@ -380,6 +387,7 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_ scope: PermissionGrantScope::Turn, strict_auto_review: true, }, + codex_exec_server::LOCAL_ENVIRONMENT_ID, Some(&originating_turn_state), ) .await; @@ -564,12 +572,15 @@ async fn shell_command_allows_sticky_turn_permissions_without_inline_request_per let mut active_turn = session.active_turn.lock().await; let active_turn = active_turn.as_mut().expect("active turn"); let mut turn_state = active_turn.turn_state.lock().await; - turn_state.record_granted_permissions(PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - ..Default::default() - }); + turn_state.record_granted_permissions( + codex_exec_server::LOCAL_ENVIRONMENT_ID, + PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + ..Default::default() + }, + ); } let session = Arc::new(session); diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 2663943f82..024f1904cc 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -3,6 +3,7 @@ use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::ResponseItem; use codex_sandboxing::policy_transforms::merge_permission_profiles; +use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; @@ -37,7 +38,7 @@ pub(crate) struct SessionState { pub(crate) startup_prewarm: Option, pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_sources: VecDeque, - granted_permissions: Option, + granted_permissions_by_environment_id: HashMap, next_turn_is_first: bool, } @@ -57,7 +58,7 @@ impl SessionState { startup_prewarm: None, active_connector_selection: HashSet::new(), pending_session_start_sources: VecDeque::new(), - granted_permissions: None, + granted_permissions_by_environment_id: HashMap::new(), next_turn_is_first: true, } } @@ -235,13 +236,29 @@ impl SessionState { self.pending_session_start_sources.pop_front() } - pub(crate) fn record_granted_permissions(&mut self, permissions: AdditionalPermissionProfile) { - self.granted_permissions = - merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions)); + pub(crate) fn record_granted_permissions( + &mut self, + environment_id: &str, + permissions: AdditionalPermissionProfile, + ) { + let granted_permissions = merge_permission_profiles( + self.granted_permissions_by_environment_id + .get(environment_id), + Some(&permissions), + ); + if let Some(granted_permissions) = granted_permissions { + self.granted_permissions_by_environment_id + .insert(environment_id.to_string(), granted_permissions); + } } - pub(crate) fn granted_permissions(&self) -> Option { - self.granted_permissions.clone() + pub(crate) fn granted_permissions( + &self, + environment_id: &str, + ) -> Option { + self.granted_permissions_by_environment_id + .get(environment_id) + .cloned() } } diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index fe09235197..eaf26ba853 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -1,6 +1,5 @@ //! Turn-scoped state and active turn metadata scaffolding. -use codex_sandboxing::policy_transforms::merge_permission_profiles; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; @@ -10,11 +9,12 @@ use tokio_util::task::AbortOnDropHandle; use codex_extension_api::ExtensionData; use codex_protocol::dynamic_tools::DynamicToolResponse; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_sandboxing::policy_transforms::merge_permission_profiles; use rmcp::model::RequestId; use tokio::sync::oneshot; @@ -90,7 +90,7 @@ pub(crate) struct TurnState { pending_dynamic_tools: HashMap>, pub(crate) pending_input: TurnInputQueue, mailbox_delivery_phase: MailboxDeliveryPhase, - granted_permissions: Option, + granted_permissions_by_environment_id: HashMap, strict_auto_review_enabled: bool, pub(crate) tool_calls: u64, pub(crate) has_memory_citation: bool, @@ -100,7 +100,7 @@ pub(crate) struct TurnState { pub(crate) struct PendingRequestPermissions { pub(crate) tx_response: oneshot::Sender, pub(crate) requested_permissions: RequestPermissionProfile, - pub(crate) cwd: AbsolutePathBuf, + pub(crate) environment: TurnEnvironmentSelection, } impl TurnState { @@ -204,13 +204,29 @@ impl TurnState { self.mailbox_delivery_phase = phase; } - pub(crate) fn record_granted_permissions(&mut self, permissions: AdditionalPermissionProfile) { - self.granted_permissions = - merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions)); + pub(crate) fn record_granted_permissions( + &mut self, + environment_id: &str, + permissions: AdditionalPermissionProfile, + ) { + let granted_permissions = merge_permission_profiles( + self.granted_permissions_by_environment_id + .get(environment_id), + Some(&permissions), + ); + if let Some(granted_permissions) = granted_permissions { + self.granted_permissions_by_environment_id + .insert(environment_id.to_string(), granted_permissions); + } } - pub(crate) fn granted_permissions(&self) -> Option { - self.granted_permissions.clone() + pub(crate) fn granted_permissions( + &self, + environment_id: &str, + ) -> Option { + self.granted_permissions_by_environment_id + .get(environment_id) + .cloned() } pub(crate) fn enable_strict_auto_review(&mut self) { diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index bfc3ab94b3..c12ba0517e 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -265,6 +265,7 @@ fn apply_patch_payload_command(payload: &ToolPayload) -> Option { async fn effective_patch_permissions( session: &Session, turn: &TurnContext, + environment_id: &str, action: &ApplyPatchAction, cwd: &AbsolutePathBuf, ) -> ( @@ -274,8 +275,14 @@ async fn effective_patch_permissions( ) { let file_paths = file_paths_for_action(action); let granted_permissions = merge_permission_profiles( - session.granted_session_permissions().await.as_ref(), - session.granted_turn_permissions().await.as_ref(), + session + .granted_session_permissions(environment_id) + .await + .as_ref(), + session + .granted_turn_permissions(environment_id) + .await + .as_ref(), ); let base_file_system_sandbox_policy = turn.file_system_sandbox_policy(); let file_system_sandbox_policy = effective_file_system_sandbox_policy( @@ -284,6 +291,7 @@ async fn effective_patch_permissions( ); let effective_additional_permissions = apply_granted_turn_permissions( session, + environment_id, cwd.as_path(), crate::sandboxing::SandboxPermissions::UseDefault, write_permissions_for_paths(&file_paths, &file_system_sandbox_policy, cwd), @@ -353,8 +361,14 @@ impl ToolExecutor for ApplyPatchHandler { { codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => { let (file_paths, effective_additional_permissions, file_system_sandbox_policy) = - effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes, &cwd) - .await; + effective_patch_permissions( + session.as_ref(), + turn.as_ref(), + &turn_environment.environment_id, + &changes, + &cwd, + ) + .await; match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes) .await { @@ -506,7 +520,14 @@ pub(crate) async fn intercept_apply_patch( { codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => { let (approval_keys, effective_additional_permissions, file_system_sandbox_policy) = - effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes, cwd).await; + effective_patch_permissions( + session.as_ref(), + turn.as_ref(), + &turn_environment.environment_id, + &changes, + cwd, + ) + .await; match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes) .await { diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index b2cbe62907..d6963f0761 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -250,7 +250,8 @@ pub(super) fn implicit_granted_permissions( pub(super) async fn apply_granted_turn_permissions( session: &Session, - cwd: &std::path::Path, + environment_id: &str, + cwd: &Path, sandbox_permissions: SandboxPermissions, additional_permissions: Option, ) -> EffectiveAdditionalPermissions { @@ -262,8 +263,8 @@ pub(super) async fn apply_granted_turn_permissions( }; } - let granted_session_permissions = session.granted_session_permissions().await; - let granted_turn_permissions = session.granted_turn_permissions().await; + let granted_session_permissions = session.granted_session_permissions(environment_id).await; + let granted_turn_permissions = session.granted_turn_permissions(environment_id).await; let granted_permissions = merge_permission_profiles( granted_session_permissions.as_ref(), granted_turn_permissions.as_ref(), diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 70a31fe0e0..f4a89fe0ca 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -48,9 +48,13 @@ impl ToolExecutor for RequestPermissionsHandler { } }; - #[allow(deprecated)] + let Some(turn_environment) = turn.environments.primary() else { + return Err(FunctionCallError::RespondToModel( + "request_permissions requires a primary environment".to_string(), + )); + }; let mut args: RequestPermissionsArgs = - parse_arguments_with_base_path(&arguments, &turn.cwd)?; + parse_arguments_with_base_path(&arguments, &turn_environment.cwd)?; args.permissions = normalize_additional_permissions(args.permissions.into()) .map(codex_protocol::request_permissions::RequestPermissionProfile::from) .map_err(FunctionCallError::RespondToModel)?; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index c890e0d8da..504653cf76 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -84,10 +84,10 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result for ExecCommandHandler { let requested_additional_permissions = additional_permissions.clone(); let effective_additional_permissions = apply_granted_turn_permissions( context.session.as_ref(), + &turn_environment.environment_id, cwd.as_path(), sandbox_permissions, additional_permissions, From 8e4b92d294c087423215a83b0e9c8282eedc9d12 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 2 Jun 2026 11:40:35 -0700 Subject: [PATCH 05/19] [codex-analytics] Track CodexErr details in turn analytics (#25707) ## Summary - add analytics-only `CodexErr` telemetry to `codex_turn_event` while leaving existing `turn_error` unchanged - record terminal `CodexErr` facts from core immediately before the existing turn error event is sent - emit source-truth `codex_error_*` fields for downstream analytics, including the raw `CodexErr::InvalidRequest(String)` message as `codex_error_subreason` ## Validation - `just test -p codex-analytics` - attempted `just test -p codex-core`, but the local run timed out across unrelated integration suites in this environment and is not being used as validation --- .../analytics/src/analytics_client_tests.rs | 33 ++++ codex-rs/analytics/src/client.rs | 7 + codex-rs/analytics/src/events.rs | 4 + codex-rs/analytics/src/facts.rs | 146 ++++++++++++++++++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 39 +++++ codex-rs/core/src/compact.rs | 2 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/compact_remote_v2.rs | 1 + codex-rs/core/src/session/mod.rs | 12 ++ codex-rs/core/src/session/turn.rs | 4 +- 11 files changed, 249 insertions(+), 1 deletion(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index b3f6ba22ce..61ab5f6b23 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -62,6 +62,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnCodexErrorFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerRequestError; @@ -132,6 +133,7 @@ use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; +use codex_protocol::error::CodexErr; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -3256,6 +3258,9 @@ fn turn_event_serializes_expected_shape() { is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, + codex_error_kind: None, + codex_error_subreason: None, + codex_error_http_status_code: None, steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, @@ -3318,6 +3323,9 @@ fn turn_event_serializes_expected_shape() { "is_first_turn": true, "status": "completed", "turn_error": null, + "codex_error_kind": null, + "codex_error_subreason": null, + "codex_error_http_status_code": null, "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, @@ -3983,6 +3991,18 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_token_usage*/ false, ) .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnCodexError(Box::new( + TurnCodexErrorFact::from_codex_err( + "thread-2".to_string(), + "turn-2".to_string(), + &CodexErr::InvalidRequest("unknown turn environment id `env-2`".to_string()), + ), + ))), + &mut out, + ) + .await; reducer .ingest( AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( @@ -3999,6 +4019,18 @@ async fn turn_lifecycle_emits_failed_turn_event() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["status"], json!("failed")); assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); + assert_eq!( + payload["event_params"]["codex_error_kind"], + json!("invalid_request") + ); + assert_eq!( + payload["event_params"]["codex_error_subreason"], + json!("unknown turn environment id `env-2`") + ); + assert_eq!( + payload["event_params"]["codex_error_http_status_code"], + json!(null) + ); } #[tokio::test] @@ -4031,6 +4063,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["status"], json!("interrupted")); assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["codex_error_kind"], json!(null)); } #[tokio::test] diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index fbcfa32dc5..bd0726b28e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -18,6 +18,7 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnCodexErrorFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; @@ -256,6 +257,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_codex_error(&self, fact: TurnCodexErrorFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnCodexError( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index fd52fefc1e..d2e7d80545 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use std::time::Instant; use crate::facts::AcceptedLineFingerprint; use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexErrKind; use crate::facts::CompactionImplementation; use crate::facts::CompactionPhase; use crate::facts::CompactionReason; @@ -797,6 +798,9 @@ pub(crate) struct CodexTurnEventParams { pub(crate) is_first_turn: bool, pub(crate) status: Option, pub(crate) turn_error: Option, + pub(crate) codex_error_kind: Option, + pub(crate) codex_error_subreason: Option, + pub(crate) codex_error_http_status_code: Option, pub(crate) steer_count: Option, pub(crate) total_tool_call_count: Option, pub(crate) shell_command_count: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 9a85276712..4d298ffdbf 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -15,6 +15,7 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; +use codex_protocol::error::CodexErr; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; @@ -29,6 +30,9 @@ use codex_protocol::request_permissions::RequestPermissionsResponse; use serde::Serialize; use std::path::PathBuf; +const INVALID_REQUEST_SUBREASON_MAX_BYTES: usize = 512; +const INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX: &str = "..."; + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct AcceptedLineFingerprint { pub path_hash: String, @@ -99,6 +103,147 @@ pub struct TurnTokenUsageFact { pub token_usage: TokenUsage, } +#[derive(Clone)] +pub struct TurnCodexErrorFact { + pub(crate) turn_id: String, + pub(crate) thread_id: String, + pub(crate) error: TurnCodexError, +} + +impl TurnCodexErrorFact { + pub fn from_codex_err(thread_id: String, turn_id: String, error: &CodexErr) -> Self { + Self { + turn_id, + thread_id, + error: TurnCodexError::from_codex_err(error), + } + } +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CodexErrKind { + TurnAborted, + Stream, + ContextWindowExceeded, + ThreadNotFound, + AgentLimitReached, + SessionConfiguredNotFirstEvent, + Timeout, + RequestTimeout, + Spawn, + Interrupted, + UnexpectedStatus, + InvalidRequest, + InvalidImageRequest, + UsageLimitReached, + ServerOverloaded, + CyberPolicy, + ResponseStreamFailed, + ConnectionFailed, + QuotaExceeded, + UsageNotIncluded, + InternalServerError, + RetryLimit, + InternalAgentDied, + Sandbox, + LandlockSandboxExecutableNotProvided, + UnsupportedOperation, + RefreshTokenFailed, + Fatal, + Io, + Json, + #[cfg(target_os = "linux")] + LandlockRuleset, + #[cfg(target_os = "linux")] + LandlockPathFd, + TokioJoin, + EnvVar, +} + +#[derive(Clone)] +pub(crate) struct TurnCodexError { + pub(crate) kind: CodexErrKind, + pub(crate) subreason: Option, + pub(crate) http_status_code: Option, +} + +impl TurnCodexError { + fn from_codex_err(error: &CodexErr) -> Self { + Self { + kind: error.into(), + subreason: match error { + CodexErr::InvalidRequest(message) => { + // InvalidRequest can contain raw provider response bodies, so bound the + // analytics copy without changing the source CodexErr. + let subreason = if message.len() <= INVALID_REQUEST_SUBREASON_MAX_BYTES { + message.clone() + } else { + let truncated_len = message.floor_char_boundary( + INVALID_REQUEST_SUBREASON_MAX_BYTES + .saturating_sub(INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX.len()), + ); + format!( + "{}{INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX}", + &message[..truncated_len] + ) + }; + Some(subreason) + } + _ => None, + }, + http_status_code: error.http_status_code_value(), + } + } +} + +impl From<&CodexErr> for CodexErrKind { + fn from(error: &CodexErr) -> Self { + match error { + CodexErr::TurnAborted => CodexErrKind::TurnAborted, + CodexErr::Stream(..) => CodexErrKind::Stream, + CodexErr::ContextWindowExceeded => CodexErrKind::ContextWindowExceeded, + CodexErr::ThreadNotFound(_) => CodexErrKind::ThreadNotFound, + CodexErr::AgentLimitReached { .. } => CodexErrKind::AgentLimitReached, + CodexErr::SessionConfiguredNotFirstEvent => { + CodexErrKind::SessionConfiguredNotFirstEvent + } + CodexErr::Timeout => CodexErrKind::Timeout, + CodexErr::RequestTimeout => CodexErrKind::RequestTimeout, + CodexErr::Spawn => CodexErrKind::Spawn, + CodexErr::Interrupted => CodexErrKind::Interrupted, + CodexErr::UnexpectedStatus(_) => CodexErrKind::UnexpectedStatus, + CodexErr::InvalidRequest(_) => CodexErrKind::InvalidRequest, + CodexErr::InvalidImageRequest() => CodexErrKind::InvalidImageRequest, + CodexErr::UsageLimitReached(_) => CodexErrKind::UsageLimitReached, + CodexErr::ServerOverloaded => CodexErrKind::ServerOverloaded, + CodexErr::CyberPolicy { .. } => CodexErrKind::CyberPolicy, + CodexErr::ResponseStreamFailed(_) => CodexErrKind::ResponseStreamFailed, + CodexErr::ConnectionFailed(_) => CodexErrKind::ConnectionFailed, + CodexErr::QuotaExceeded => CodexErrKind::QuotaExceeded, + CodexErr::UsageNotIncluded => CodexErrKind::UsageNotIncluded, + CodexErr::InternalServerError => CodexErrKind::InternalServerError, + CodexErr::RetryLimit(_) => CodexErrKind::RetryLimit, + CodexErr::InternalAgentDied => CodexErrKind::InternalAgentDied, + CodexErr::Sandbox(_) => CodexErrKind::Sandbox, + CodexErr::LandlockSandboxExecutableNotProvided => { + CodexErrKind::LandlockSandboxExecutableNotProvided + } + CodexErr::UnsupportedOperation(_) => CodexErrKind::UnsupportedOperation, + CodexErr::RefreshTokenFailed(_) => CodexErrKind::RefreshTokenFailed, + CodexErr::Fatal(_) => CodexErrKind::Fatal, + CodexErr::Io(_) => CodexErrKind::Io, + CodexErr::Json(_) => CodexErrKind::Json, + #[cfg(target_os = "linux")] + CodexErr::LandlockRuleset(_) => CodexErrKind::LandlockRuleset, + #[cfg(target_os = "linux")] + CodexErr::LandlockPathFd(_) => CodexErrKind::LandlockPathFd, + CodexErr::TokioJoin(_) => CodexErrKind::TokioJoin, + CodexErr::EnvVar(_) => CodexErrKind::EnvVar, + } + } +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -329,6 +474,7 @@ pub(crate) enum CustomAnalyticsFact { GuardianReview(Box), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnCodexError(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index a33ca7b9e3..c227f4daf1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -38,6 +38,7 @@ pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnCodexErrorFact; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; pub use facts::TurnSteerRejectionReason; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f206393476..46f69c59f1 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -70,6 +70,8 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TurnCodexError; +use crate::facts::TurnCodexErrorFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -322,6 +324,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + codex_error: Option, latest_diff: Option, steer_count: usize, tool_counts: TurnToolCounts, @@ -461,6 +464,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out).await; } + CustomAnalyticsFact::TurnCodexError(input) => { + self.ingest_turn_codex_error(*input); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -606,6 +612,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -630,6 +637,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -639,6 +647,29 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out).await; } + fn ingest_turn_codex_error(&mut self, input: TurnCodexErrorFact) { + let TurnCodexErrorFact { + turn_id, + thread_id, + error, + } = input; + let turn_state = self.turns.entry(turn_id).or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + token_usage: None, + completed: None, + codex_error: None, + latest_diff: None, + steer_count: 0, + tool_counts: TurnToolCounts::default(), + }); + turn_state.thread_id.get_or_insert(thread_id); + turn_state.codex_error = Some(error); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -795,6 +826,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -1154,6 +1186,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -1175,6 +1208,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -1194,6 +1228,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -2451,6 +2486,7 @@ fn codex_turn_event_params( is_first_turn, } = resolved_config; let token_usage = turn_state.token_usage.clone(); + let codex_error = turn_state.codex_error.as_ref(); CodexTurnEventParams { thread_id, session_id: thread_metadata.session_id.clone(), @@ -2483,6 +2519,9 @@ fn codex_turn_event_params( is_first_turn, status: completed.status, turn_error: completed.turn_error, + codex_error_kind: codex_error.map(|error| error.kind), + codex_error_subreason: codex_error.and_then(|error| error.subreason.clone()), + codex_error_http_status_code: codex_error.and_then(|error| error.http_status_code), steer_count: Some(turn_state.steer_count), total_tool_call_count: Some(turn_state.tool_counts.total), shell_command_count: Some(turn_state.tool_counts.shell_command), diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 11386e6bcd..a002ce20d6 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -240,6 +240,7 @@ async fn run_compact_task_inner_impl( continue; } sess.set_total_tokens_full(turn_context.as_ref()).await; + sess.track_turn_codex_error(turn_context.as_ref(), &e); let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); sess.send_event(&turn_context, event).await; return Err(e); @@ -257,6 +258,7 @@ async fn run_compact_task_inner_impl( tokio::time::sleep(delay).await; continue; } else { + sess.track_turn_codex_error(turn_context.as_ref(), &e); let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); sess.send_event(&turn_context, event).await; return Err(e); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index b4456dc717..c2ef417b9b 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -138,6 +138,7 @@ async fn run_remote_compact_task_inner( } attempt.track(sess.as_ref(), status, error.clone()).await; if let Err(err) = result { + sess.track_turn_codex_error(turn_context, &err); let event = EventMsg::Error( err.to_error_event(Some("Error running remote compact task".to_string())), ); diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 0da3017f7d..eb10eb211d 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -155,6 +155,7 @@ async fn run_remote_compact_task_inner( } attempt.track(sess.as_ref(), status, error.clone()).await; if let Err(err) = result { + sess.track_turn_codex_error(turn_context, &err); let event = EventMsg::Error( err.to_error_event(Some("Error running remote compact task".to_string())), ); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index d8f660e2a1..7c49cd742e 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -45,6 +45,7 @@ use chrono::Local; use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::SubAgentThreadStartedInput; +use codex_analytics::TurnCodexErrorFact; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_config::types::OAuthCredentialsStoreMode; @@ -1634,6 +1635,17 @@ impl Session { ) } + /// Record a terminal CodexErr before the app-server completion notification is reduced. + pub(crate) fn track_turn_codex_error(&self, turn_context: &TurnContext, error: &CodexErr) { + self.services + .analytics_events_client + .track_turn_codex_error(TurnCodexErrorFact::from_codex_err( + self.conversation_id.to_string(), + turn_context.sub_id.clone(), + error, + )); + } + /// Persist the event to rollout and send it to clients. pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) { let legacy_source = msg.clone(); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index d1e9e59f70..62f8d9a497 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -369,7 +369,7 @@ pub(crate) async fn run_turn( // Aborted turn is reported via a different event. break; } - Err(CodexErr::InvalidImageRequest()) => { + Err(codex_error @ CodexErr::InvalidImageRequest()) => { { let mut state = sess.state.lock().await; error_or_panic( @@ -380,6 +380,7 @@ pub(crate) async fn run_turn( } } + sess.track_turn_codex_error(turn_context.as_ref(), &codex_error); let error = CodexErrorInfo::BadRequest; sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) .await; @@ -405,6 +406,7 @@ pub(crate) async fn run_turn( { warn!("failed to usage-limit active goal after usage-limit error: {err}"); } + sess.track_turn_codex_error(turn_context.as_ref(), &e); let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); sess.send_event(&turn_context, event).await; // let the user continue the conversation From e29071e4c9bf724f9679b7bcf2038d74cc44acd1 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 2 Jun 2026 20:51:25 +0200 Subject: [PATCH 06/19] Add environmentId to request_permissions (#25858) ## Stack 1. #25850 - Key request-permission grants by environment: stores and applies sticky permission grants per environment id. 2. This PR (#25858) - Add `environmentId` to `request_permissions`: lets the model target a selected environment and resolves relative permission paths against it. 3. #25862 - Propagate permission approval environment id: carries the selected environment id through approval events, app-server requests, TUI prompts, and delegate forwarding. 4. #25867 - Add remote request permissions integration coverage: verifies the selected remote environment across request, approval, grant reuse, and exec. This PR is stacked on #25850; #25862 and #25867 are stacked on this PR. ## Why PR1 made request-permission grants internally environment-keyed, but the model-facing `request_permissions` tool could still only target the primary environment. For CCA and multi-environment turns, the tool needs an explicit way to bind a permission request to a selected attached environment before resolving relative paths. ## What Changed - Added optional `environmentId` to `RequestPermissionsArgs`, with `environment_id` accepted as an alias. - Exposed `environmentId` in the `request_permissions` tool schema and description. - Resolve the selected environment before parsing filesystem permission paths, so relative paths bind to the selected environment cwd. - Route validated tool calls through `request_permissions_for_environment` directly instead of duplicating environment lookup in `Session::request_permissions`. - Reject unknown environment ids with a model-facing error. - Updated focused request-permissions and Guardian call sites for the new optional field. ## Testing Not run locally per instruction. --- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/session/mod.rs | 24 ------- codex-rs/core/src/session/tests.rs | 63 +++++++++++++++++-- .../core/src/session/tests/guardian_tests.rs | 18 +++++- .../src/tools/handlers/request_permissions.rs | 22 ++++++- .../core/src/tools/handlers/shell_spec.rs | 9 ++- .../src/tools/handlers/shell_spec_tests.rs | 7 +++ codex-rs/protocol/src/request_permissions.rs | 8 +++ 8 files changed, 119 insertions(+), 33 deletions(-) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 3132420e4c..f5f04d2dbd 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -755,6 +755,7 @@ async fn handle_request_permissions( ) { let call_id = event.call_id; let args = RequestPermissionsArgs { + environment_id: None, reason: event.reason, permissions: event.permissions, }; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 7c49cd742e..0abd3c6ec7 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2112,30 +2112,6 @@ impl Session { rx_approve } - pub async fn request_permissions( - self: &Arc, - turn_context: &Arc, - call_id: String, - args: RequestPermissionsArgs, - cancellation_token: CancellationToken, - ) -> Option { - let Some(turn_environment) = turn_context.environments.primary() else { - return Some(RequestPermissionsResponse { - permissions: RequestPermissionProfile::default(), - scope: PermissionGrantScope::Turn, - strict_auto_review: false, - }); - }; - self.request_permissions_for_environment( - turn_context, - call_id, - args, - turn_environment.selection(), - cancellation_token, - ) - .await - } - #[expect( clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 260e91c092..6f54e54b14 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5365,11 +5365,17 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() let turn_context = Arc::clone(&turn_context); let call_id = call_id.clone(); async move { + let environment = turn_context + .environments + .primary() + .expect("primary environment") + .selection(); session - .request_permissions( + .request_permissions_for_environment( &turn_context, call_id, codex_protocol::request_permissions::RequestPermissionsArgs { + environment_id: None, reason: Some("need network".to_string()), permissions: RequestPermissionProfile { network: Some(codex_protocol::models::NetworkPermissions { @@ -5378,6 +5384,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() ..RequestPermissionProfile::default() }, }, + environment, CancellationToken::new(), ) .await @@ -5409,7 +5416,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() } #[tokio::test] -async fn request_permissions_tool_resolves_relative_paths_against_primary_environment() { +async fn request_permissions_tool_resolves_relative_paths_against_selected_environment() { let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; *session.active_turn.lock().await = Some(ActiveTurn::default()); let environment_cwd = { @@ -5429,6 +5436,7 @@ async fn request_permissions_tool_resolves_relative_paths_against_primary_enviro mcp_elicitations: true, })) .expect("test setup should allow updating approval policy"); + turn_context_mut.environments.turn_environments[0].environment_id = "remote".to_string(); turn_context_mut.environments.turn_environments[0].cwd = environment_cwd.clone(); let call_id = "call-1".to_string(); @@ -5451,6 +5459,7 @@ async fn request_permissions_tool_resolves_relative_paths_against_primary_enviro source: ToolCallSource::Direct, payload: ToolPayload::Function { arguments: json!({ + "environment_id": "remote", "reason": "need write", "permissions": { "file_system": { @@ -5509,6 +5518,38 @@ async fn request_permissions_tool_resolves_relative_paths_against_primary_enviro .expect("request_permissions handler should succeed"); } +#[tokio::test] +async fn request_permissions_tool_rejects_unknown_environment_id() { + let (session, turn_context) = make_session_and_context().await; + let result = RequestPermissionsHandler + .handle(ToolInvocation { + session: Arc::new(session), + turn: Arc::new(turn_context), + cancellation_token: CancellationToken::new(), + tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), + call_id: "call-1".to_string(), + tool_name: codex_tools::ToolName::plain("request_permissions"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: json!({ + "environment_id": "missing", + "permissions": { + "network": { + "enabled": true, + }, + }, + }) + .to_string(), + }, + }) + .await; + + let Err(FunctionCallError::RespondToModel(output)) = result else { + panic!("expected unknown environment id to be rejected"); + }; + assert_eq!(output, "unknown turn environment id `missing`"); +} + #[tokio::test] async fn request_permissions_response_materializes_session_cwd_grants_before_recording() { let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; @@ -5547,14 +5588,21 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec let call_id = call_id.clone(); let requested_permissions = requested_permissions.clone(); async move { + let environment = turn_context + .environments + .primary() + .expect("primary environment") + .selection(); session - .request_permissions( + .request_permissions_for_environment( &turn_context, call_id, codex_protocol::request_permissions::RequestPermissionsArgs { + environment_id: None, reason: Some("need cwd write".to_string()), permissions: requested_permissions, }, + environment, CancellationToken::new(), ) .await @@ -5627,11 +5675,17 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req let session = Arc::new(session); let turn_context = Arc::new(turn_context); let call_id = "call-1".to_string(); + let environment = turn_context + .environments + .primary() + .expect("primary environment") + .selection(); let response = session - .request_permissions( + .request_permissions_for_environment( &turn_context, call_id, codex_protocol::request_permissions::RequestPermissionsArgs { + environment_id: None, reason: Some("need network".to_string()), permissions: RequestPermissionProfile { network: Some(codex_protocol::models::NetworkPermissions { @@ -5640,6 +5694,7 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req ..RequestPermissionProfile::default() }, }, + environment, CancellationToken::new(), ) .await; diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index e8fccd7d72..d8b46c9999 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -124,15 +124,22 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() { }), ..RequestPermissionProfile::default() }; + let environment = turn_context + .environments + .primary() + .expect("primary environment") + .selection(); let response = tokio::time::timeout( Duration::from_secs(45), - session.request_permissions( + session.request_permissions_for_environment( &turn_context, "perm-call-1".to_string(), RequestPermissionsArgs { + environment_id: None, reason: Some("need network".to_string()), permissions: requested_permissions.clone(), }, + environment, CancellationToken::new(), ), ) @@ -213,14 +220,21 @@ async fn request_permissions_guardian_review_stops_when_cancelled() { let requested_permissions = requested_permissions.clone(); let cancellation_token = cancellation_token.clone(); async move { + let environment = turn_context + .environments + .primary() + .expect("primary environment") + .selection(); session - .request_permissions( + .request_permissions_for_environment( &turn_context, "perm-call-cancelled".to_string(), RequestPermissionsArgs { + environment_id: None, reason: Some("need network".to_string()), permissions: requested_permissions, }, + environment, cancellation_token, ) .await diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index f4a89fe0ca..a20ff2b665 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -6,16 +6,25 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::context::boxed_tool_output; +use crate::tools::handlers::parse_arguments; use crate::tools::handlers::parse_arguments_with_base_path; +use crate::tools::handlers::resolve_tool_environment; use crate::tools::handlers::shell_spec::create_request_permissions_tool; use crate::tools::handlers::shell_spec::request_permissions_tool_description; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; use codex_tools::ToolName; use codex_tools::ToolSpec; +use serde::Deserialize; pub struct RequestPermissionsHandler; +#[derive(Deserialize)] +struct RequestPermissionsEnvironmentArgs { + #[serde(default, rename = "environment_id", alias = "environmentId")] + environment_id: Option, +} + #[async_trait::async_trait] impl ToolExecutor for RequestPermissionsHandler { fn tool_name(&self) -> ToolName { @@ -48,7 +57,10 @@ impl ToolExecutor for RequestPermissionsHandler { } }; - let Some(turn_environment) = turn.environments.primary() else { + let environment_args: RequestPermissionsEnvironmentArgs = parse_arguments(&arguments)?; + let Some(turn_environment) = + resolve_tool_environment(turn.as_ref(), environment_args.environment_id.as_deref())? + else { return Err(FunctionCallError::RespondToModel( "request_permissions requires a primary environment".to_string(), )); @@ -65,7 +77,13 @@ impl ToolExecutor for RequestPermissionsHandler { } let response = session - .request_permissions(&turn, call_id, args, cancellation_token) + .request_permissions_for_environment( + &turn, + call_id, + args, + turn_environment.selection(), + cancellation_token, + ) .await .ok_or_else(|| { FunctionCallError::RespondToModel( diff --git a/codex-rs/core/src/tools/handlers/shell_spec.rs b/codex-rs/core/src/tools/handlers/shell_spec.rs index 13c531eaba..3fb563b8d9 100644 --- a/codex-rs/core/src/tools/handlers/shell_spec.rs +++ b/codex-rs/core/src/tools/handlers/shell_spec.rs @@ -229,6 +229,13 @@ pub fn create_request_permissions_tool(description: String) -> ToolSpec { "Optional short explanation for why additional permissions are needed.".to_string(), )), ), + ( + "environment_id".to_string(), + JsonSchema::string(Some( + "Environment id from . Omit to use the primary environment." + .to_string(), + )), + ), ("permissions".to_string(), permission_profile_schema()), ]); @@ -247,7 +254,7 @@ pub fn create_request_permissions_tool(description: String) -> ToolSpec { } pub fn request_permissions_tool_description() -> String { - "Request additional filesystem or network permissions from the user and wait for the client to grant a subset of the requested permission profile. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session if the client approves them at session scope." + "Request additional filesystem or network permissions from the user and wait for the client to grant a subset of the requested permission profile. Use environment_id to target a specific attached environment; omit it to use the primary environment. Relative filesystem paths resolve against the selected environment cwd. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session if the client approves them at session scope." .to_string() } diff --git a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs index b7c0db7242..b7bbfa54a1 100644 --- a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs @@ -172,6 +172,13 @@ fn request_permissions_tool_includes_full_permission_schema() { "Optional short explanation for why additional permissions are needed.".to_string(), )), ), + ( + "environment_id".to_string(), + JsonSchema::string(Some( + "Environment id from . Omit to use the primary environment." + .to_string(), + )), + ), ("permissions".to_string(), permission_profile_schema()), ]); diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index be6b88ef52..43e8b8ee9d 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -48,6 +48,14 @@ impl From for RequestPermissionProfile { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct RequestPermissionsArgs { + #[serde( + default, + rename = "environment_id", + alias = "environmentId", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional)] + pub environment_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, pub permissions: RequestPermissionProfile, From 57f337a8e90360649112f8fc01d9ee2f9f2afa24 Mon Sep 17 00:00:00 2001 From: Won Park Date: Tue, 2 Jun 2026 12:00:04 -0700 Subject: [PATCH 07/19] Route standalone image generation through host finalization md (#25176) ## Why Standalone image-generation extensions emitted turn items through the low-level event path, bypassing host-owned finalization such as image persistence and contributor processing. At the same time, the generated-image save-path hint must remain visible to the model through the extension tool's `FunctionCallOutput`, rather than the legacy built-in developer-message path. ## What changed - Extended `ExtensionTurnItem` to support image-generation items while keeping the extension-facing emitter API limited to `emit_started` and `emit_completed`. - Routed extension completion through core `finalize_turn_item`, so standalone image-generation items receive host-owned processing and persisted `saved_path` values before publication. - Kept legacy built-in image generation on its existing developer-message hint path, while standalone image generation returns its deterministic saved-path hint in `FunctionCallOutput`. - Shared the image artifact path and output-hint formatting used by core and the image-generation extension. - Passed thread identity through extension tool calls so standalone image generation can construct the same intended artifact path as core. - Added an app-server integration test covering real standalone image generation, saved artifact publication, model-visible output hint wiring, and absence of the legacy developer-message hint. ## Validation - `just fmt` - `just test -p codex-image-generation-extension` - `just test -p codex-web-search-extension` - `just test -p codex-goal-extension` - `just test -p codex-memories-extension` - Targeted `codex-core` tests for image save history, extension completion finalization, and contributor execution - `just test -p codex-app-server standalone_image_generation_returns_saved_path_hint_to_model` - `just fix -p codex-core` - `just fix -p codex-image-generation-extension` - `just bazel-lock-update` - `just bazel-lock-check` --- .../tests/suite/v2/image_generation.rs | 214 ++++++++++++++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + codex-rs/core/src/stream_events_utils.rs | 72 +++--- .../src/tools/handlers/extension_tools.rs | 170 +++++++------- codex-rs/ext/extension-api/src/lib.rs | 1 - codex-rs/ext/image-generation/src/tests.rs | 17 +- codex-rs/ext/image-generation/src/tool.rs | 37 +-- codex-rs/tools/src/lib.rs | 1 - codex-rs/tools/src/tool_call.rs | 28 +-- 9 files changed, 385 insertions(+), 156 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/image_generation.rs diff --git a/codex-rs/app-server/tests/suite/v2/image_generation.rs b/codex-rs/app-server/tests/suite/v2/image_generation.rs new file mode 100644 index 0000000000..4752f1497e --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/image_generation.rs @@ -0,0 +1,214 @@ +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_config::types::AuthCredentialsStoreMode; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const RESULT: &str = "cG5n"; + +// macOS and Windows Bazel CI can spend tens of seconds starting app-server +// subprocesses or processing test RPCs under load. +#[cfg(any(target_os = "macos", windows))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); +#[cfg(not(any(target_os = "macos", windows)))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> Result<()> { + let call_id = "image-run-1"; + let server = responses::start_mock_server().await; + mount_image_response(&server).await; + + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + call_id, + "image_gen", + "imagegen", + &json!({ + "action": "generate", + "prompt": "paint a blue whale", + }) + .to_string(), + ), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "Generate an image".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp)?; + + let completed = timeout( + DEFAULT_READ_TIMEOUT, + wait_for_image_generation_completed(&mut mcp), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let ThreadItem::ImageGeneration { + status, + revised_prompt, + result, + saved_path: Some(saved_path), + .. + } = completed.item + else { + panic!("expected completed image generation item with saved path"); + }; + assert_eq!(status, "completed"); + assert_eq!(revised_prompt.as_deref(), Some("paint a blue whale")); + assert_eq!(result, RESULT); + assert_eq!(std::fs::read(&saved_path)?, b"png"); + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let output = requests[1].function_call_output(call_id); + assert_eq!( + output["output"][0], + json!({ + "type": "input_image", + "image_url": format!("data:image/png;base64,{RESULT}"), + "detail": "high", + }) + ); + assert_eq!(output["output"].as_array().map(Vec::len), Some(1)); + assert!( + !requests[1] + .message_input_texts("developer") + .iter() + .any(|text| text.contains("Generated images are saved to")), + "standalone image generation should not emit the legacy developer-message hint" + ); + + Ok(()) +} + +async fn wait_for_image_generation_completed( + mcp: &mut McpProcess, +) -> Result { + loop { + let notification = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let completed: ItemCompletedNotification = serde_json::from_value( + notification + .params + .context("item/completed notification should include params")?, + )?; + if matches!(&completed.item, ThreadItem::ImageGeneration { .. }) { + return Ok(completed); + } + } +} + +async fn mount_image_response(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/api/codex/images/generations")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "created": 1, + "data": [{"b64_json": RESULT}], + }))) + .expect(1) + .mount(server) + .await; +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +model_provider = "openai-custom" +chatgpt_base_url = "{server_uri}" + +[features] +imagegenext = true + +[model_providers.openai-custom] +name = "OpenAI" +base_url = "{server_uri}/api/codex" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +supports_websockets = false +requires_openai_auth = true +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 1605983705..950cccebb5 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -17,6 +17,7 @@ mod experimental_feature_list; mod external_agent_config; mod fs; mod hooks_list; +mod image_generation; mod initialize; mod marketplace_add; mod marketplace_remove; diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index bb1f5fba65..7914c8d482 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -131,6 +131,7 @@ pub(crate) async fn persist_image_generation_item( turn_context: &TurnContext, image_item: &mut ImageGenerationItem, ) -> Option { + image_item.saved_path = None; let session_id = sess.conversation_id.to_string(); match save_image_generation_result( &turn_context.config.codex_home, @@ -163,15 +164,12 @@ pub(crate) async fn persist_image_generation_item( } } -pub(crate) async fn finalize_image_generation_item( +async fn record_image_generation_instructions( sess: &Session, turn_context: &TurnContext, - image_item: &mut ImageGenerationItem, + image_item: &ImageGenerationItem, ) { - if persist_image_generation_item(sess, turn_context, image_item) - .await - .is_none() - { + if image_item.saved_path.is_none() { return; } let session_id = sess.conversation_id.to_string(); @@ -530,27 +528,16 @@ pub(crate) async fn handle_non_tool_response_item( | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } => { let mut turn_item = parse_turn_item(item)?; - if let TurnItemContributorPolicy::Run(turn_store) = contributor_policy { - apply_turn_item_contributors(sess, turn_store, &mut turn_item).await; - } - if let TurnItem::AgentMessage(agent_message) = &mut turn_item { - let combined = agent_message - .content - .iter() - .map(|entry| match entry { - codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(), - }) - .collect::(); - let (stripped, memory_citation) = - strip_hidden_assistant_markup_and_parse_memory_citation(&combined, plan_mode); - agent_message.content = - vec![codex_protocol::items::AgentMessageContent::Text { text: stripped }]; - if agent_message.memory_citation.is_none() { - agent_message.memory_citation = memory_citation; - } - } - if let TurnItem::ImageGeneration(image_item) = &mut turn_item { - finalize_image_generation_item(sess, turn_context, image_item).await; + finalize_turn_item( + sess, + turn_context, + contributor_policy, + &mut turn_item, + plan_mode, + ) + .await; + if let TurnItem::ImageGeneration(image_item) = &turn_item { + record_image_generation_instructions(sess, turn_context, image_item).await; } Some(turn_item) } @@ -564,6 +551,37 @@ pub(crate) async fn handle_non_tool_response_item( } } +pub(crate) async fn finalize_turn_item( + sess: &Session, + turn_context: &TurnContext, + contributor_policy: TurnItemContributorPolicy<'_>, + turn_item: &mut TurnItem, + plan_mode: bool, +) { + if let TurnItemContributorPolicy::Run(turn_store) = contributor_policy { + apply_turn_item_contributors(sess, turn_store, turn_item).await; + } + if let TurnItem::AgentMessage(agent_message) = &mut *turn_item { + let combined = agent_message + .content + .iter() + .map(|entry| match entry { + codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(), + }) + .collect::(); + let (stripped, memory_citation) = + strip_hidden_assistant_markup_and_parse_memory_citation(&combined, plan_mode); + agent_message.content = + vec![codex_protocol::items::AgentMessageContent::Text { text: stripped }]; + if agent_message.memory_citation.is_none() { + agent_message.memory_citation = memory_citation; + } + } + if let TurnItem::ImageGeneration(image_item) = &mut *turn_item { + persist_image_generation_item(sess, turn_context, image_item).await; + } +} + pub(crate) fn last_assistant_message_from_item( item: &ResponseItem, plan_mode: bool, diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 4ad88ac5bb..0a1e2e58c8 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -4,7 +4,6 @@ use std::sync::Weak; use codex_protocol::items::TurnItem; use codex_tools::ConversationHistory; use codex_tools::ExtensionTurnItem; -use codex_tools::ImageGenerationCompletionFuture; use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolName; use codex_tools::ToolSearchInfo; @@ -12,12 +11,11 @@ use codex_tools::ToolSpec; use codex_tools::TurnItemEmissionFuture; use codex_tools::TurnItemEmitter; -use crate::context::ContextualUserFragment; -use crate::context::ImageGenerationInstructions; use crate::function_tool::FunctionCallError; use crate::session::session::Session; use crate::session::turn_context::TurnContext; -use crate::stream_events_utils::persist_image_generation_item; +use crate::stream_events_utils::TurnItemContributorPolicy; +use crate::stream_events_utils::finalize_turn_item; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -76,6 +74,10 @@ struct CoreTurnItemEmitter { fn extension_turn_item(item: ExtensionTurnItem) -> TurnItem { match item { ExtensionTurnItem::WebSearch(item) => TurnItem::WebSearch(item), + ExtensionTurnItem::ImageGeneration(mut item) => { + item.saved_path = None; + TurnItem::ImageGeneration(item) + } } } @@ -85,8 +87,9 @@ impl TurnItemEmitter for CoreTurnItemEmitter { let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { return; }; - let item = extension_turn_item(item); - session.emit_turn_item_started(turn.as_ref(), &item).await; + session + .emit_turn_item_started(turn.as_ref(), &extension_turn_item(item)) + .await; }) } @@ -95,54 +98,18 @@ impl TurnItemEmitter for CoreTurnItemEmitter { let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { return; }; - let item = extension_turn_item(item); + let mut item = extension_turn_item(item); + finalize_turn_item( + session.as_ref(), + turn.as_ref(), + TurnItemContributorPolicy::Run(turn.extension_data.as_ref()), + &mut item, + turn.collaboration_mode.mode == codex_protocol::config_types::ModeKind::Plan, + ) + .await; session.emit_turn_item_completed(turn.as_ref(), item).await; }) } - - fn image_generation_completed<'a>( - &'a self, - call_id: String, - prompt: String, - result: String, - ) -> ImageGenerationCompletionFuture<'a> { - Box::pin(async move { - let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { - return None; - }; - let mut item = codex_protocol::items::ImageGenerationItem { - id: call_id, - status: "completed".to_string(), - revised_prompt: Some(prompt), - result, - saved_path: None, - }; - let output_hint = - persist_image_generation_item(session.as_ref(), turn.as_ref(), &mut item) - .await - .map(|saved_path| { - let output_dir = saved_path - .parent() - .unwrap_or_else(|| turn.config.codex_home.clone()); - ImageGenerationInstructions::new(output_dir.display(), saved_path.display()) - .body() - }); - let started_item = codex_protocol::items::ImageGenerationItem { - id: item.id.clone(), - status: "in_progress".to_string(), - revised_prompt: None, - result: String::new(), - saved_path: None, - }; - session - .emit_turn_item_started(turn.as_ref(), &TurnItem::ImageGeneration(started_item)) - .await; - session - .emit_turn_item_completed(turn.as_ref(), TurnItem::ImageGeneration(item)) - .await; - output_hint - }) - } } async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { @@ -167,6 +134,8 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { mod tests { use std::sync::Arc; + use codex_extension_api::ExtensionData; + use codex_extension_api::TurnItemContributor; use codex_protocol::items::TurnItem; use codex_protocol::items::WebSearchItem; use codex_protocol::models::ContentItem; @@ -174,10 +143,13 @@ mod tests { use codex_protocol::models::WebSearchAction; use codex_protocol::protocol::EventMsg; use codex_tools::ExtensionTurnItem; + use codex_utils_absolute_path::test_support::PathExt; + use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; use serde_json::json; use tokio::sync::Mutex; + use super::CoreTurnItemEmitter; use super::ExtensionToolAdapter; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; @@ -409,8 +381,54 @@ mod tests { assert_eq!(end.action, expected.action); } - struct ImageGenerationExtensionExecutor { - output_hint: Arc>>, + struct ImageGenerationExtensionExecutor; + + #[derive(Debug)] + struct ExtensionTurnItemContributorRan; + + struct RecordExtensionTurnItemContributor; + + #[async_trait::async_trait] + impl TurnItemContributor for RecordExtensionTurnItemContributor { + async fn contribute( + &self, + _thread_store: &ExtensionData, + turn_store: &ExtensionData, + _item: &mut TurnItem, + ) -> Result<(), String> { + turn_store.insert(ExtensionTurnItemContributorRan); + Ok(()) + } + } + + #[tokio::test] + async fn extension_completion_runs_turn_item_contributors() { + let (mut session, turn) = crate::session::tests::make_session_and_context().await; + let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); + builder.turn_item_contributor(Arc::new(RecordExtensionTurnItemContributor)); + session.services.extensions = Arc::new(builder.build()); + let session = Arc::new(session); + let turn = Arc::new(turn); + let emitter = CoreTurnItemEmitter { + session: Arc::downgrade(&session), + turn: Arc::downgrade(&turn), + }; + + codex_tools::TurnItemEmitter::emit_completed( + &emitter, + ExtensionTurnItem::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: "contributors".to_string(), + action: WebSearchAction::Other, + }), + ) + .await; + + assert!( + turn.extension_data + .get::() + .is_some() + ); } #[async_trait::async_trait] @@ -434,15 +452,28 @@ mod tests { &self, call: codex_tools::ToolCall, ) -> Result, codex_tools::FunctionCallError> { - let output_hint = call - .turn_item_emitter - .image_generation_completed( - call.call_id, - "A tiny blue square".to_string(), - "cG5n".to_string(), - ) + call.turn_item_emitter + .emit_started(ExtensionTurnItem::ImageGeneration( + codex_protocol::items::ImageGenerationItem { + id: call.call_id.clone(), + status: "in_progress".to_string(), + revised_prompt: None, + result: String::new(), + saved_path: None, + }, + )) + .await; + call.turn_item_emitter + .emit_completed(ExtensionTurnItem::ImageGeneration( + codex_protocol::items::ImageGenerationItem { + id: call.call_id, + status: "completed".to_string(), + revised_prompt: Some("A tiny blue square".to_string()), + result: "cG5n".to_string(), + saved_path: Some(test_path_buf("/tmp/extension-claimed.png").abs()), + }, + )) .await; - *self.output_hint.lock().await = output_hint; Ok(Box::new(codex_tools::JsonToolOutput::new( json!({ "ok": true }), ))) @@ -451,10 +482,7 @@ mod tests { #[tokio::test] async fn image_generation_publication_is_finalized_by_core() { - let output_hint = Arc::new(Mutex::new(None)); - let handler = ExtensionToolAdapter::new(Arc::new(ImageGenerationExtensionExecutor { - output_hint: Arc::clone(&output_hint), - })); + let handler = ExtensionToolAdapter::new(Arc::new(ImageGenerationExtensionExecutor)); let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await; let expected_path = crate::stream_events_utils::image_generation_artifact_path( &turn.config.codex_home, @@ -521,17 +549,5 @@ mod tests { std::fs::read(&expected_path).expect("generated artifact should be saved"), b"png" ); - assert_eq!( - *output_hint.lock().await, - Some(format!( - "Generated images are saved to {} as {} by default.\n\ - If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.", - expected_path - .parent() - .expect("generated image path should have a parent") - .display(), - expected_path.display(), - )) - ); } } diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 2f00a8dd98..4705581c8a 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -13,7 +13,6 @@ pub use capabilities::ResponseItemInjector; pub use codex_tools::ConversationHistory; pub use codex_tools::ExtensionTurnItem; pub use codex_tools::FunctionCallError; -pub use codex_tools::ImageGenerationCompletionFuture; pub use codex_tools::JsonToolOutput; pub use codex_tools::NoopTurnItemEmitter; pub use codex_tools::ResponsesApiTool; diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index f866a9018b..44f2d80ebe 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -26,7 +26,6 @@ use crate::IMAGE_GEN_NAMESPACE; use crate::IMAGEGEN_TOOL_NAME; const RESULT: &str = "cG5n"; -const OUTPUT_HINT: &str = "Generated images are saved to /tmp as /tmp/call-1.png by default."; #[test] fn uses_reserved_image_gen_namespace() { @@ -55,10 +54,9 @@ fn generate_uses_fixed_request_defaults() { } #[test] -fn generated_output_returns_image_input_and_output_hint() { +fn generated_output_returns_image_input() { let output = GeneratedImageOutput { result: RESULT.to_string(), - output_hint: Some(OUTPUT_HINT.to_string()), }; let ResponseInputItem::FunctionCallOutput { @@ -73,15 +71,10 @@ fn generated_output_returns_image_input_and_output_hint() { }; assert_eq!( content_items, - vec![ - FunctionCallOutputContentItem::InputImage { - image_url: format!("data:image/png;base64,{RESULT}"), - detail: Some(DEFAULT_IMAGE_DETAIL), - }, - FunctionCallOutputContentItem::InputText { - text: OUTPUT_HINT.to_string(), - }, - ] + vec![FunctionCallOutputContentItem::InputImage { + image_url: format!("data:image/png;base64,{RESULT}"), + detail: Some(DEFAULT_IMAGE_DETAIL), + }] ); } diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 75a79db2bf..41d407d912 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -3,6 +3,7 @@ use codex_api::ImageEditRequest; use codex_api::ImageGenerationRequest; use codex_api::ImageQuality; use codex_api::ImageUrl; +use codex_extension_api::ExtensionTurnItem; use codex_extension_api::FunctionCallError; use codex_extension_api::ToolCall; use codex_extension_api::ToolExecutor; @@ -11,6 +12,7 @@ use codex_extension_api::ToolOutput; use codex_extension_api::ToolPayload; use codex_extension_api::ToolSpec; use codex_extension_api::parse_tool_input_schema; +use codex_protocol::items::ImageGenerationItem; use codex_protocol::models::ContentItem; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputBody; @@ -84,6 +86,15 @@ impl ToolExecutor for ImageGenerationTool { async fn handle(&self, call: ToolCall) -> Result, FunctionCallError> { let args = parse_args(&call)?; let request = request_for_action(&args, call.conversation_history.items())?; + call.turn_item_emitter + .emit_started(ExtensionTurnItem::ImageGeneration(ImageGenerationItem { + id: call.call_id.clone(), + status: "in_progress".to_string(), + revised_prompt: None, + result: String::new(), + saved_path: None, + })) + .await; let response = match request { ImageRequest::Generate(request) => self.backend.generate(request).await, ImageRequest::Edit(request) => self.backend.edit(request).await, @@ -96,14 +107,16 @@ impl ToolExecutor for ImageGenerationTool { "image generation returned no image data".to_string(), )); }; - let output_hint = call - .turn_item_emitter - .image_generation_completed(call.call_id.clone(), args.prompt, result.clone()) + call.turn_item_emitter + .emit_completed(ExtensionTurnItem::ImageGeneration(ImageGenerationItem { + id: call.call_id.clone(), + status: "completed".to_string(), + revised_prompt: Some(args.prompt), + result: result.clone(), + saved_path: None, + })) .await; - Ok(Box::new(GeneratedImageOutput { - result, - output_hint, - })) + Ok(Box::new(GeneratedImageOutput { result })) } } @@ -284,7 +297,6 @@ fn imagegen_tool_spec() -> ToolSpec { struct GeneratedImageOutput { result: String, - output_hint: Option, } impl ToolOutput for GeneratedImageOutput { @@ -298,17 +310,12 @@ impl ToolOutput for GeneratedImageOutput { true } - /// Returns generated bytes and persisted-artifact context for model follow-up. + /// Returns generated bytes for model follow-up. fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { - let mut content = vec![FunctionCallOutputContentItem::InputImage { + let content = vec![FunctionCallOutputContentItem::InputImage { image_url: format!("data:image/png;base64,{}", self.result), detail: Some(DEFAULT_IMAGE_DETAIL), }]; - if let Some(output_hint) = &self.output_hint { - content.push(FunctionCallOutputContentItem::InputText { - text: output_hint.clone(), - }); - } ResponseInputItem::FunctionCallOutput { call_id: call_id.to_string(), output: FunctionCallOutputPayload { diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 28c4f93bba..1cee3f7d2d 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -64,7 +64,6 @@ pub use responses_api::mcp_tool_to_responses_api_tool; pub use responses_api::tool_definition_to_responses_api_tool; pub use tool_call::ConversationHistory; pub use tool_call::ExtensionTurnItem; -pub use tool_call::ImageGenerationCompletionFuture; pub use tool_call::NoopTurnItemEmitter; pub use tool_call::ToolCall; pub use tool_call::TurnItemEmissionFuture; diff --git a/codex-rs/tools/src/tool_call.rs b/codex-rs/tools/src/tool_call.rs index 31143f9f1f..e3fc21e864 100644 --- a/codex-rs/tools/src/tool_call.rs +++ b/codex-rs/tools/src/tool_call.rs @@ -1,6 +1,7 @@ use crate::FunctionCallError; use crate::ToolName; use crate::ToolPayload; +use codex_protocol::items::ImageGenerationItem; use codex_protocol::items::WebSearchItem; use codex_protocol::models::ResponseItem; use codex_utils_output_truncation::TruncationPolicy; @@ -29,20 +30,14 @@ impl ConversationHistory { /// Future returned when an extension tool emits a visible turn-item lifecycle event. pub type TurnItemEmissionFuture<'a> = Pin + Send + 'a>>; -/// Future returned when an image-generation extension publishes completed image bytes. -pub type ImageGenerationCompletionFuture<'a> = - Pin> + Send + 'a>>; - -/// Visible turn items that an extension fully owns and may emit as-is. -/// -/// Add only item kinds that require no additional host finalization before -/// persistence or client delivery. Richer items need a host-owned publish path. +/// Visible turn items that an extension may publish into the host lifecycle. #[derive(Clone, Debug, PartialEq)] pub enum ExtensionTurnItem { WebSearch(WebSearchItem), + ImageGeneration(ImageGenerationItem), } -/// Host-provided capability for extension tools to emit finalized visible turn items. +/// Host-provided capability for extension tools to emit visible turn items. /// /// Implementations route lifecycle events through the host's normal item event /// pipeline, including any persistence and client delivery owned by the host. @@ -50,21 +45,8 @@ pub trait TurnItemEmitter: Send + Sync { /// Emits the beginning of one visible turn item. fn emit_started<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a>; - /// Emits the completion of one visible turn item. + /// Emits one visible turn item after host-owned finalization. fn emit_completed<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a>; - - /// Publishes image bytes for host persistence and visible completion. - /// - /// Returns persisted-artifact context for the extension's model-facing - /// function output when the host saves the generated image successfully. - fn image_generation_completed<'a>( - &'a self, - _call_id: String, - _prompt: String, - _result: String, - ) -> ImageGenerationCompletionFuture<'a> { - Box::pin(std::future::ready(None)) - } } /// Turn-item emitter used when a caller does not expose visible item emission. From de124c32be47b01e2204269f26bd1f00ab15bb56 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 2 Jun 2026 12:05:52 -0700 Subject: [PATCH 08/19] Fix Windows release PDB staging (#25916) ## Summary - Teach the Windows release prebuild staging step to locate Rust/MSVC PDBs emitted with crate-style underscore names. - Stage PDBs under the shipped hyphenated binary names so the downstream symbol archive step keeps the same artifact contract. - Keep a fallback for already-hyphenated PDB names and fail with a clear diagnostic if neither form exists. ## Root cause The recent symbol publishing change in #25649 started copying `${binary}.pdb` from `target//release` during Windows prebuild staging. Cargo still emits the `.exe` with the hyphenated binary name, but MSVC PDBs for hyphenated Rust crates are emitted with underscores, for example `codex_app_server.pdb` for `codex-app-server.exe`. The release workflow was still building into the expected directory; the new PDB copy step was looking for the wrong filename. ## Impact This unblocks the `rust-release` Windows prebuilt-binary jobs for hyphenated binaries while preserving the hyphenated PDB names consumed by the final Windows release packaging and symbol archive steps. ## Validation - `just fmt` from `codex-rs` - `git diff --check -- .github/workflows/rust-release-windows.yml` - Parsed `.github/workflows/rust-release-windows.yml` as YAML locally - Local bash staging sanity test for both underscore-emitted and hyphenated PDB filenames --- .github/workflows/rust-release-windows.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index f0fd6b90fc..fc14088ef1 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -134,11 +134,22 @@ jobs: - name: Stage Windows binaries shell: bash run: | - output_dir="target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}" + release_dir="target/${{ matrix.target }}/release" + output_dir="$release_dir/staged-${{ matrix.bundle }}" mkdir -p "$output_dir" for binary in ${{ matrix.binaries }}; do - cp "target/${{ matrix.target }}/release/${binary}.exe" "$output_dir/${binary}.exe" - cp "target/${{ matrix.target }}/release/${binary}.pdb" "$output_dir/${binary}.pdb" + pdb_name="${binary//-/_}" + pdb_path="$release_dir/${pdb_name}.pdb" + if [[ ! -f "$pdb_path" ]]; then + pdb_path="$release_dir/${binary}.pdb" + fi + if [[ ! -f "$pdb_path" ]]; then + echo "PDB for $binary not found at $release_dir/${pdb_name}.pdb or $release_dir/${binary}.pdb" >&2 + exit 1 + fi + + cp "$release_dir/${binary}.exe" "$output_dir/${binary}.exe" + cp "$pdb_path" "$output_dir/${binary}.pdb" done - name: Upload Windows binaries From 9de568372da86141ced85e4153494b0b159f2171 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 2 Jun 2026 21:09:34 +0200 Subject: [PATCH 09/19] Propagate permission approval environment id (#25862) ## Stack 1. #25850 - Key request-permission grants by environment: stores and applies sticky permission grants per environment id. 2. #25858 - Add `environmentId` to `request_permissions`: lets the model target a selected environment and resolves relative permission paths against it. 3. This PR (#25862) - Propagate permission approval environment id: carries the selected environment id through approval events, app-server requests, TUI prompts, and delegate forwarding. 4. #25867 - Add remote request permissions integration coverage: verifies the selected remote environment across request, approval, grant reuse, and exec. This PR is stacked on #25858, and #25867 is stacked on this PR. ## Why PR2 lets the model bind a `request_permissions` call to a selected environment, but the approval event and client-facing request still needed to carry that binding. For CCA, the user-facing prompt and delegated approval path should know which environment the grant applies to instead of relying on cwd alone. ## What Changed - Added optional `environmentId` to `RequestPermissionsEvent`. - Emit the selected environment id from core permission approval events. - Preserve the environment id through delegate forwarding, including cwd-based delegated requests. - Added `environmentId` to app-server permission approval params, generated schema/TypeScript artifacts, and README examples. - Preserve and display the environment id in TUI permission approval prompts. - Updated focused core, app-server protocol, and TUI conversion coverage. ## Testing Not run locally per instruction. Performed read-only `git diff --check`. --- codex-rs/analytics/src/analytics_client_tests.rs | 1 + .../json/PermissionsRequestApprovalParams.json | 9 ++++++++- .../schema/json/ServerRequest.json | 9 ++++++++- .../json/codex_app_server_protocol.schemas.json | 9 ++++++++- .../v2/PermissionsRequestApprovalParams.ts | 2 +- .../src/protocol/v2/permissions.rs | 2 ++ .../app-server-protocol/src/protocol/v2/tests.rs | 2 ++ codex-rs/app-server/README.md | 3 ++- codex-rs/app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/codex_delegate.rs | 2 +- codex-rs/core/src/codex_delegate_tests.rs | 6 +++++- codex-rs/core/src/session/mod.rs | 13 +++++++++++-- codex-rs/core/src/session/tests.rs | 9 +++++++++ codex-rs/protocol/src/request_permissions.rs | 9 +++++++++ codex-rs/tui/src/app/app_server_requests.rs | 1 + codex-rs/tui/src/app/event_dispatch.rs | 8 ++++++++ codex-rs/tui/src/app/tests.rs | 6 +++++- codex-rs/tui/src/app/thread_routing.rs | 1 + codex-rs/tui/src/bottom_pane/approval_overlay.rs | 10 ++++++++++ codex-rs/tui/src/chatwidget.rs | 1 + .../tui/src/chatwidget/tests/approval_requests.rs | 2 ++ codex-rs/tui/src/chatwidget/tool_requests.rs | 1 + 22 files changed, 97 insertions(+), 10 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 61ab5f6b23..0a11412efd 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -835,6 +835,7 @@ fn sample_permissions_approval_request(request_id: i64) -> ServerRequest { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "permissions-1".to_string(), + environment_id: None, started_at_ms: 1_000, cwd: test_path_buf("/tmp").abs(), reason: Some("need network".to_string()), diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index 337a469890..961e24c2c2 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -285,6 +285,13 @@ "cwd": { "$ref": "#/definitions/AbsolutePathBuf" }, + "environmentId": { + "default": null, + "type": [ + "null", + "string" + ] + }, "itemId": { "type": "string" }, @@ -319,4 +326,4 @@ ], "title": "PermissionsRequestApprovalParams", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 411095f2c2..13bac2e118 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -1590,6 +1590,13 @@ "cwd": { "$ref": "#/definitions/AbsolutePathBuf" }, + "environmentId": { + "default": null, + "type": [ + "null", + "string" + ] + }, "itemId": { "type": "string" }, @@ -1998,4 +2005,4 @@ } ], "title": "ServerRequest" -} \ No newline at end of file +} 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 cdb8f95538..6aa4c5f35e 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 @@ -3783,6 +3783,13 @@ "cwd": { "$ref": "#/definitions/v2/AbsolutePathBuf" }, + "environmentId": { + "default": null, + "type": [ + "null", + "string" + ] + }, "itemId": { "type": "string" }, @@ -19232,4 +19239,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts index 509f60923b..d0677f5f0c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts @@ -4,7 +4,7 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { RequestPermissionProfile } from "./RequestPermissionProfile"; -export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, +export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, environmentId: string | null, /** * Unix timestamp (in milliseconds) when this approval request started. */ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index dfc1942276..45fc5c7e14 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -663,6 +663,8 @@ pub struct PermissionsRequestApprovalParams { pub thread_id: String, pub turn_id: String, pub item_id: String, + #[serde(default)] + pub environment_id: Option, /// Unix timestamp (in milliseconds) when this approval request started. #[ts(type = "number")] pub started_at_ms: i64, 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 1e7558363d..1c1e92b36f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -400,6 +400,7 @@ fn permissions_request_approval_uses_request_permission_profile() { "threadId": "thr_123", "turnId": "turn_123", "itemId": "call_123", + "environmentId": "remote", "startedAtMs": 1, "cwd": absolute_path_string("repo"), "reason": "Select a workspace root", @@ -416,6 +417,7 @@ fn permissions_request_approval_uses_request_permission_profile() { .expect("permissions request should deserialize"); assert_eq!(params.cwd, absolute_path("repo")); + assert_eq!(params.environment_id.as_deref(), Some("remote")); assert_eq!( params.permissions, RequestPermissionProfile { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index f3b55337ce..fdc34e524c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1379,7 +1379,7 @@ the client can offer session-scoped and/or persistent approval choices. ### Permission requests -The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. The `cwd` field identifies the directory used to resolve project-root permissions and relative deny globs. +The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. The `environmentId` and `cwd` fields identify the environment and directory used to resolve project-root permissions and relative deny globs. ```json { @@ -1389,6 +1389,7 @@ The built-in `request_permissions` tool sends an `item/permissions/requestApprov "threadId": "thr_123", "turnId": "turn_123", "itemId": "call_123", + "environmentId": "local", "cwd": "/Users/me/project", "reason": "Select a workspace root", "permissions": { diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 43e3d305bb..f4a5f3b45f 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -761,6 +761,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id: request.turn_id.clone(), item_id: request.call_id.clone(), + environment_id: request.environment_id.clone(), started_at_ms: request.started_at_ms, cwd: request_cwd.clone(), reason: request.reason, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index f5f04d2dbd..678a6e5d10 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -755,7 +755,7 @@ async fn handle_request_permissions( ) { let call_id = event.call_id; let args = RequestPermissionsArgs { - environment_id: None, + environment_id: event.environment_id, reason: event.reason, permissions: event.permissions, }; diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 64e0c16416..7dabb09d33 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -183,9 +183,11 @@ async fn run_codex_thread_interactive_respects_pre_cancelled_spawn() { #[tokio::test] async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { - let (parent_session, parent_ctx, rx_events) = + let (parent_session, mut parent_ctx, rx_events) = crate::session::tests::make_session_and_context_with_rx().await; *parent_session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + let parent_ctx_mut = Arc::get_mut(&mut parent_ctx).expect("single turn context ref"); + parent_ctx_mut.environments.turn_environments[0].environment_id = "remote".to_string(); let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY); let (_tx_events, rx_events_child) = bounded(SUBMISSION_CHANNEL_CAPACITY); @@ -228,6 +230,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { RequestPermissionsEvent { call_id: request_call_id, turn_id: "child-turn-1".to_string(), + environment_id: Some("remote".to_string()), started_at_ms: 0, reason: Some("need access".to_string()), permissions: RequestPermissionProfile { @@ -252,6 +255,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { panic!("expected RequestPermissions event"); }; assert_eq!(request.call_id, call_id.clone()); + assert_eq!(request.environment_id.as_deref(), Some("remote")); assert_eq!(request.cwd, Some(delegated_cwd)); parent_session diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 0abd3c6ec7..5a92c9c825 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2251,6 +2251,7 @@ impl Session { let event = EventMsg::RequestPermissions(RequestPermissionsEvent { call_id: call_id.clone(), turn_id: turn_context.sub_id.clone(), + environment_id: Some(environment.environment_id.clone()), started_at_ms: now_unix_timestamp_ms(), reason: args.reason, permissions: requested_permissions, @@ -2279,14 +2280,22 @@ impl Session { cwd: AbsolutePathBuf, cancellation_token: CancellationToken, ) -> Option { - let Some(primary_environment) = turn_context.environments.primary() else { + let turn_environment = match args.environment_id.as_deref() { + Some(environment_id) => turn_context + .environments + .turn_environments + .iter() + .find(|environment| environment.environment_id == environment_id), + None => turn_context.environments.primary(), + }; + let Some(turn_environment) = turn_environment else { return Some(RequestPermissionsResponse { permissions: RequestPermissionProfile::default(), scope: PermissionGrantScope::Turn, strict_auto_review: false, }); }; - let mut environment = primary_environment.selection(); + let mut environment = turn_environment.selection(); environment.cwd = cwd; self.request_permissions_for_environment( turn_context, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 6f54e54b14..30043937c7 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5399,6 +5399,10 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() panic!("expected request_permissions event"); }; assert_eq!(request.call_id, call_id); + assert_eq!( + request.environment_id.as_deref(), + Some(codex_exec_server::LOCAL_ENVIRONMENT_ID) + ); #[allow(deprecated)] let turn_cwd = turn_context.cwd.clone(); assert_eq!(request.cwd, Some(turn_cwd)); @@ -5499,6 +5503,7 @@ async fn request_permissions_tool_resolves_relative_paths_against_selected_envir }), ..Default::default() }; + assert_eq!(request.environment_id.as_deref(), Some("remote")); assert_eq!(request.permissions, expected_permissions); session @@ -5616,6 +5621,10 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec let EventMsg::RequestPermissions(request) = request_event.msg else { panic!("expected request_permissions event"); }; + assert_eq!( + request.environment_id.as_deref(), + Some(codex_exec_server::LOCAL_ENVIRONMENT_ID) + ); let request_cwd = request.cwd.clone().expect("request cwd"); session diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index 43e8b8ee9d..1752363ec5 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -79,6 +79,15 @@ pub struct RequestPermissionsEvent { /// Uses `#[serde(default)]` for backwards compatibility. #[serde(default)] pub turn_id: String, + #[serde( + default, + rename = "environmentId", + alias = "environment_id", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional)] + #[ts(rename = "environmentId")] + pub environment_id: Option, #[ts(type = "number")] pub started_at_ms: i64, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index 1843333023..c2580bd075 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -489,6 +489,7 @@ mod tests { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "perm-1".to_string(), + environment_id: None, started_at_ms: 0, cwd: absolute_path(if cfg!(windows) { r"C:\tmp" } else { "/tmp" }), reason: None, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 43e1179841..3570491d1a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1856,12 +1856,20 @@ impl App { )); } ApprovalRequest::Permissions { + environment_id, permissions, reason, .. } => { let _ = tui.enter_alt_screen(); let mut lines = Vec::new(); + if let Some(environment_id) = environment_id { + lines.push(Line::from(vec![ + "Environment: ".into(), + environment_id.bold(), + ])); + lines.push(Line::from("")); + } if let Some(reason) = reason { lines.push(Line::from(vec!["Reason: ".into(), reason.italic()])); lines.push(Line::from("")); diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index f33f689acd..eb5e10c50f 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2539,6 +2539,7 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions( thread_id: thread_id.to_string(), turn_id: "turn-approval".to_string(), item_id: "call-approval".to_string(), + environment_id: Some("remote".to_string()), started_at_ms: 0, cwd: test_absolute_path("/tmp"), reason: Some("Need access to .git".to_string()), @@ -2557,7 +2558,9 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions( }; let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Permissions { - permissions, .. + environment_id, + permissions, + .. })) = app .interactive_request_for_thread_request(thread_id, &request) .await @@ -2565,6 +2568,7 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions( panic!("expected permissions approval request"); }; + assert_eq!(environment_id.as_deref(), Some("remote")); assert_eq!( permissions, RequestPermissionProfile { diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index f2c5969f2f..1bd28d5f65 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -310,6 +310,7 @@ impl App { thread_id, thread_label, call_id: params.item_id.clone(), + environment_id: params.environment_id.clone(), reason: params.reason.clone(), permissions: params.permissions.clone().into(), }), diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 17b17f3598..f318dd2103 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -84,6 +84,7 @@ pub(crate) enum ApprovalRequest { thread_id: ThreadId, thread_label: Option, call_id: String, + environment_id: Option, reason: Option, permissions: RequestPermissionProfile, }, @@ -713,6 +714,7 @@ fn build_header(request: &ApprovalRequest) -> Box { } ApprovalRequest::Permissions { thread_label, + environment_id, reason, permissions, .. @@ -725,6 +727,13 @@ fn build_header(request: &ApprovalRequest) -> Box { ])); header.push(Line::from("")); } + if let Some(environment_id) = environment_id { + header.push(Line::from(vec![ + "Environment: ".into(), + environment_id.clone().bold(), + ])); + header.push(Line::from("")); + } if let Some(reason) = reason { header.push(Line::from(vec!["Reason: ".into(), reason.clone().italic()])); header.push(Line::from("")); @@ -1227,6 +1236,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, call_id: "test".to_string(), + environment_id: None, reason: Some("need workspace access".to_string()), permissions: RequestPermissionProfile { network: Some(NetworkPermissions { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 246e16d69c..c9827a4e71 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -864,6 +864,7 @@ fn request_permissions_from_params( RequestPermissionsEvent { turn_id: params.turn_id, call_id: params.item_id, + environment_id: params.environment_id, started_at_ms: params.started_at_ms, reason: params.reason, permissions: params.permissions.into(), diff --git a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs index d732177ce4..18fc155649 100644 --- a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs +++ b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs @@ -281,6 +281,7 @@ fn app_server_request_permissions_preserves_file_system_permissions() { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "item-1".to_string(), + environment_id: Some("remote".to_string()), started_at_ms: 0, cwd: cwd.clone(), reason: Some("Select a workspace root".to_string()), @@ -310,6 +311,7 @@ fn app_server_request_permissions_preserves_file_system_permissions() { } ); assert_eq!(request.cwd, Some(cwd)); + assert_eq!(request.environment_id.as_deref(), Some("remote")); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tool_requests.rs b/codex-rs/tui/src/chatwidget/tool_requests.rs index 58bbd7cef0..3001563451 100644 --- a/codex-rs/tui/src/chatwidget/tool_requests.rs +++ b/codex-rs/tui/src/chatwidget/tool_requests.rs @@ -441,6 +441,7 @@ impl ChatWidget { thread_id: self.thread_id.unwrap_or_default(), thread_label: None, call_id: ev.call_id, + environment_id: ev.environment_id, reason: ev.reason, permissions: ev.permissions, }; From bc49677ec8aeed439c45832802918b36b1c04b5f Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 2 Jun 2026 12:27:01 -0700 Subject: [PATCH 10/19] [codex] Pin Python SDK to glibc-compatible runtime (#25907) ## Summary - pin the Python SDK runtime package to `openai-codex-cli-bin==0.136.0` so Ubuntu/glibc installs resolve a compatible wheel - refresh generated SDK artifacts and lock data for the runtime update - keep newly generated client-message-id wire models internal to the generated protocol layer ## Dependency - merge #25906 first so the Python SDK release publishes both manylinux and musllinux runtime wheels before publishing the package with this pin ## Validation - ran `just fmt` - regenerated the Python public API helpers - validated the edited workflow YAML - CI passed 29/29 checks --- .github/workflows/python-sdk-release.yml | 6 +- .github/workflows/sdk.yml | 6 +- sdk/python/pyproject.toml | 6 +- sdk/python/scripts/update_sdk_artifacts.py | 6 +- .../generated/notification_registry.py | 2 + .../src/openai_codex/generated/v2_all.py | 446 +++++++++++++++--- .../test_artifact_workflow_and_binaries.py | 25 +- sdk/python/tests/test_contract_generation.py | 2 +- sdk/python/uv.lock | 18 +- 9 files changed, 407 insertions(+), 110 deletions(-) diff --git a/.github/workflows/python-sdk-release.yml b/.github/workflows/python-sdk-release.yml index 654dd8d822..96870db171 100644 --- a/.github/workflows/python-sdk-release.yml +++ b/.github/workflows/python-sdk-release.yml @@ -34,8 +34,8 @@ jobs: exit 1 fi - # The pinned runtime currently publishes a musllinux Linux wheel. - # Build in Alpine so release type generation installs that wheel. + # Build in a glibc Linux image so release type generation installs + # the pinned manylinux runtime wheel. docker run --rm \ --user "$(id -u):$(id -g)" \ -e HOME=/tmp/codex-python-sdk-home \ @@ -46,7 +46,7 @@ jobs: -v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ -v "${RUNNER_TEMP}:${RUNNER_TEMP}" \ -w "${GITHUB_WORKSPACE}/sdk/python" \ - python:3.12-alpine \ + python:3.12-slim \ sh -euxc ' python -m venv /tmp/release-tools /tmp/release-tools/bin/python -m pip install build twine uv==0.11.3 diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 54ed8dc558..4103a948cd 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -23,15 +23,15 @@ jobs: run: | set -euo pipefail - # Run inside Alpine so dependency resolution exercises the pinned - # runtime wheel on the same Linux wheel family that CI installs. + # Run inside a glibc Linux image so dependency resolution exercises + # the pinned manylinux runtime wheel that users install. docker run --rm \ --user "$(id -u):$(id -g)" \ -e HOME=/tmp/codex-python-sdk-home \ -e UV_LINK_MODE=copy \ -v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ -w "${GITHUB_WORKSPACE}/sdk/python" \ - python:3.12-alpine \ + python:3.12-slim \ sh -euxc ' python -m venv /tmp/uv /tmp/uv/bin/python -m pip install uv==0.11.3 diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 9c93be69a5..6b7bbddd87 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ "Intended Audience :: Developers", "Topic :: Software Development :: Libraries :: Python Modules", ] -dependencies = ["pydantic>=2.12", "openai-codex-cli-bin==0.132.0"] +dependencies = ["pydantic>=2.12", "openai-codex-cli-bin==0.136.0"] [project.urls] Homepage = "https://github.com/openai/codex" @@ -78,10 +78,10 @@ combine-as-imports = true [tool.uv] exclude-newer = "7 days" -exclude-newer-package = { openai-codex-cli-bin = "2026-05-20T21:00:00Z" } +exclude-newer-package = { openai-codex-cli-bin = "2026-06-01T20:00:00Z" } index-strategy = "first-index" [tool.uv.pip] exclude-newer = "7 days" -exclude-newer-package = { openai-codex-cli-bin = "2026-05-20T21:00:00Z" } +exclude-newer-package = { openai-codex-cli-bin = "2026-06-01T20:00:00Z" } index-strategy = "first-index" diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index 1742ced547..a4af5d605e 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -1163,7 +1163,9 @@ def generate_public_api_flat_methods() -> None: turn_start_fields = _load_public_fields( "openai_codex.generated.v2_all", "TurnStartParams", - exclude={"thread_id", "input", *approval_fields}, + # Keep the wire model current without exposing this app-server field + # through the ergonomic Python API yet. + exclude={"thread_id", "input", "client_user_message_id", *approval_fields}, ) turn_start_fields = _replace_public_sandbox_field(turn_start_fields, wire_name="sandbox_policy") @@ -1267,7 +1269,7 @@ def build_parser() -> argparse.ArgumentParser: "--platform-tag", help=( "Optional wheel platform tag override, for example " - "macosx_11_0_arm64 or musllinux_1_1_x86_64." + "macosx_11_0_arm64 or manylinux_2_17_x86_64." ), ) return parser diff --git a/sdk/python/src/openai_codex/generated/notification_registry.py b/sdk/python/src/openai_codex/generated/notification_registry.py index c55eb5b9b7..d5e620a7a3 100644 --- a/sdk/python/src/openai_codex/generated/notification_registry.py +++ b/sdk/python/src/openai_codex/generated/notification_registry.py @@ -57,6 +57,7 @@ from .v2_all import ThreadRealtimeSdpNotification from .v2_all import ThreadRealtimeStartedNotification from .v2_all import ThreadRealtimeTranscriptDeltaNotification from .v2_all import ThreadRealtimeTranscriptDoneNotification +from .v2_all import ThreadSettingsUpdatedNotification from .v2_all import ThreadStartedNotification from .v2_all import ThreadStatusChangedNotification from .v2_all import ThreadTokenUsageUpdatedNotification @@ -122,6 +123,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = { "thread/realtime/started": ThreadRealtimeStartedNotification, "thread/realtime/transcript/delta": ThreadRealtimeTranscriptDeltaNotification, "thread/realtime/transcript/done": ThreadRealtimeTranscriptDoneNotification, + "thread/settings/updated": ThreadSettingsUpdatedNotification, "thread/started": ThreadStartedNotification, "thread/status/changed": ThreadStatusChangedNotification, "thread/tokenUsage/updated": ThreadTokenUsageUpdatedNotification, diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 85120b8293..c27a8f75ca 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -56,7 +56,7 @@ class ActivePermissionProfile(BaseModel): extends: Annotated[ str | None, Field( - description="Parent profile identifier once permissions profiles support inheritance. This is currently always `null`." + description="Parent profile identifier from the selected permissions profile's `extends` setting, when present." ), ] = None id: Annotated[ @@ -77,6 +77,11 @@ class AddCreditsNudgeEmailStatus(Enum): cooldown_active = "cooldown_active" +class AdditionalContextKind(Enum): + untrusted = "untrusted" + application = "application" + + class AdditionalNetworkPermissions(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -251,6 +256,11 @@ class AuthMode(Enum): agent_identity = "agentIdentity" +class AutoCompactTokenLimitScope(Enum): + total = "total" + body_after_prefix = "body_after_prefix" + + class AutoReviewDecisionSource(RootModel[Literal["agent"]]): model_config = ConfigDict( populate_by_name=True, @@ -563,6 +573,13 @@ class CommandMigration(BaseModel): name: str +class ComputerUseRequirements(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + allow_locked_computer_use: Annotated[bool | None, Field(alias="allowLockedComputerUse")] = None + + class MdmConfigLayerSource(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -675,7 +692,7 @@ class ConfigReadParams(BaseModel): description="Optional working directory to resolve project config layers. If specified, return the effective config as seen from that directory (i.e., including any project layers between `cwd` and the project/repo root)." ), ] = None - include_layers: Annotated[bool | None, Field(alias="includeLayers")] = False + include_layers: Annotated[bool | None, Field(alias="includeLayers")] = None class CommandConfiguredHookHandler(BaseModel): @@ -913,7 +930,7 @@ class FeedbackUploadParams(BaseModel): ) classification: str extra_log_files: Annotated[list[str] | None, Field(alias="extraLogFiles")] = None - include_logs: Annotated[bool, Field(alias="includeLogs")] + include_logs: Annotated[bool | None, Field(alias="includeLogs")] = None reason: str | None = None tags: dict[str, Any] | None = None thread_id: Annotated[str | None, Field(alias="threadId")] = None @@ -939,7 +956,7 @@ class FileChangeOutputDeltaNotification(BaseModel): class FileSystemAccessMode(Enum): read = "read" write = "write" - none = "none" + deny = "deny" class PathFileSystemPath(BaseModel): @@ -1276,6 +1293,17 @@ class InputTextFunctionCallOutputContentItem(BaseModel): ] +class EncryptedContentFunctionCallOutputContentItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + encrypted_content: str + type: Annotated[ + Literal["encrypted_content"], + Field(title="EncryptedContentFunctionCallOutputContentItemType"), + ] + + class FuzzyFileSearchMatchType(Enum): file = "file" directory = "directory" @@ -1335,7 +1363,7 @@ class GetAccountParams(BaseModel): alias="refreshToken", description="When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.", ), - ] = False + ] = None class GitInfo(BaseModel): @@ -1425,6 +1453,8 @@ class HookEventName(Enum): post_compact = "postCompact" session_start = "sessionStart" user_prompt_submit = "userPromptSubmit" + subagent_start = "subagentStart" + subagent_stop = "subagentStop" stop = "stop" @@ -1506,6 +1536,8 @@ class HooksListParams(BaseModel): class ImageDetail(Enum): + auto = "auto" + low = "low" high = "high" original = "original" @@ -1742,6 +1774,8 @@ class ManagedHooksRequirements(BaseModel): pre_tool_use: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="PreToolUse")] session_start: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="SessionStart")] stop: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="Stop")] + subagent_start: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="SubagentStart")] + subagent_stop: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="SubagentStop")] user_prompt_submit: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="UserPromptSubmit")] managed_dir: Annotated[str | None, Field(alias="managedDir")] = None windows_managed_dir: Annotated[str | None, Field(alias="windowsManagedDir")] = None @@ -1835,6 +1869,18 @@ class McpResourceReadParams(BaseModel): uri: str +class McpServerInfo(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + description: str | None = None + icons: list | None = None + name: str + title: str | None = None + version: str + website_url: Annotated[str | None, Field(alias="websiteUrl")] = None + + class McpServerMigration(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -2142,7 +2188,7 @@ class NetworkRequirements(BaseModel): class NetworkUnixSocketPermission(Enum): allow = "allow" - none = "none" + deny = "deny" class NonSteerableTurnKind(Enum): @@ -2188,6 +2234,32 @@ class PatchChangeKind( root: AddPatchChangeKind | DeletePatchChangeKind | UpdatePatchChangeKind +class PermissionProfileListParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cursor: Annotated[ + str | None, Field(description="Opaque pagination cursor returned by a previous call.") + ] = None + cwd: Annotated[ + str | None, + Field(description="Optional working directory to resolve project config layers."), + ] = None + limit: Annotated[ + int | None, Field(description="Optional page size; defaults to the full result set.", ge=0) + ] = None + + +class PermissionProfileSummary(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + description: Annotated[ + str | None, Field(description="Optional user-facing description for display in clients.") + ] = None + id: Annotated[str, Field(description="Available permission profile identifier.")] + + class Personality(Enum): none = "none" friendly = "friendly" @@ -2332,6 +2404,7 @@ class PluginInterface(BaseModel): class PluginListMarketplaceKind(Enum): local = "local" + vertical = "vertical" workspace_directory = "workspace-directory" shared_with_me = "shared-with-me" @@ -3469,6 +3542,20 @@ class SkillsConfigWriteResponse(BaseModel): effective_enabled: Annotated[bool, Field(alias="effectiveEnabled")] +class SkillsExtraRootsSetParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + extra_roots: Annotated[list[AbsolutePathBuf], Field(alias="extraRoots")] + + +class SkillsExtraRootsSetResponse(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + class SkillsListParams(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -3624,6 +3711,20 @@ class ThreadCompactStartResponse(BaseModel): ) +class ThreadGoalClearParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadGoalClearResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cleared: bool + + class ThreadGoalClearedNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -3631,6 +3732,13 @@ class ThreadGoalClearedNotification(BaseModel): thread_id: Annotated[str, Field(alias="threadId")] +class ThreadGoalGetParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + + class ThreadGoalStatus(Enum): active = "active" paused = "paused" @@ -3749,6 +3857,7 @@ class McpToolCallThreadItem(BaseModel): error: McpToolCallError | None = None id: str mcp_app_resource_uri: Annotated[str | None, Field(alias="mcpAppResourceUri")] = None + plugin_id: Annotated[str | None, Field(alias="pluginId")] = None result: McpToolCallResult | None = None server: str status: McpToolCallStatus @@ -3922,7 +4031,7 @@ class ThreadReadParams(BaseModel): alias="includeTurns", description="When true, include turns and their items from rollout history.", ), - ] = False + ] = None thread_id: Annotated[str, Field(alias="threadId")] @@ -4536,6 +4645,14 @@ class AccountUpdatedNotification(BaseModel): plan_type: Annotated[PlanType | None, Field(alias="planType")] = None +class AdditionalContextEntry(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + kind: AdditionalContextKind + value: str + + class AppConfig(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -4629,6 +4746,24 @@ class ThreadNameSetRequest(BaseModel): params: ThreadSetNameParams +class ThreadGoalGetRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["thread/goal/get"], Field(title="Thread/goal/getRequestMethod")] + params: ThreadGoalGetParams + + +class ThreadGoalClearRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["thread/goal/clear"], Field(title="Thread/goal/clearRequestMethod")] + params: ThreadGoalClearParams + + class ThreadMetadataUpdateRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -4730,6 +4865,17 @@ class SkillsListRequest(BaseModel): params: SkillsListParams +class SkillsExtraRootsSetRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["skills/extraRoots/set"], Field(title="Skills/extraRoots/setRequestMethod") + ] + params: SkillsExtraRootsSetParams + + class HooksListRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -4995,6 +5141,17 @@ class ExperimentalFeatureListRequest(BaseModel): params: ExperimentalFeatureListParams +class PermissionProfileListRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["permissionProfile/list"], Field(title="PermissionProfile/listRequestMethod") + ] + params: PermissionProfileListParams + + class ExperimentalFeatureEnablementSetRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -5431,16 +5588,22 @@ class ConfigRequirements(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + allow_appshots: Annotated[bool | None, Field(alias="allowAppshots")] = None allow_managed_hooks_only: Annotated[bool | None, Field(alias="allowManagedHooksOnly")] = None allowed_approval_policies: Annotated[ list[AskForApproval] | None, Field(alias="allowedApprovalPolicies") ] = None + allowed_permissions: Annotated[list[str] | None, Field(alias="allowedPermissions")] = None allowed_sandbox_modes: Annotated[ list[SandboxMode] | None, Field(alias="allowedSandboxModes") ] = None allowed_web_search_modes: Annotated[ list[WebSearchMode] | None, Field(alias="allowedWebSearchModes") ] = None + allowed_windows_sandbox_implementations: Annotated[ + list[WindowsSandboxSetupMode] | None, Field(alias="allowedWindowsSandboxImplementations") + ] = None + computer_use: Annotated[ComputerUseRequirements | None, Field(alias="computerUse")] = None enforce_residency: Annotated[ResidencyRequirement | None, Field(alias="enforceResidency")] = ( None ) @@ -5608,13 +5771,19 @@ class InputImageFunctionCallOutputContentItem(BaseModel): class FunctionCallOutputContentItem( - RootModel[InputTextFunctionCallOutputContentItem | InputImageFunctionCallOutputContentItem] + RootModel[ + InputTextFunctionCallOutputContentItem + | InputImageFunctionCallOutputContentItem + | EncryptedContentFunctionCallOutputContentItem + ] ): model_config = ConfigDict( populate_by_name=True, ) root: Annotated[ - InputTextFunctionCallOutputContentItem | InputImageFunctionCallOutputContentItem, + InputTextFunctionCallOutputContentItem + | InputImageFunctionCallOutputContentItem + | EncryptedContentFunctionCallOutputContentItem, Field( description="Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs." ), @@ -5767,6 +5936,7 @@ class ListMcpServerStatusParams(BaseModel): int | None, Field(description="Optional page size; defaults to a server-defined value.", ge=0), ] = None + thread_id: Annotated[str | None, Field(alias="threadId")] = None class McpResourceReadResponse(BaseModel): @@ -5784,6 +5954,7 @@ class McpServerStatus(BaseModel): name: str resource_templates: Annotated[list[ResourceTemplate], Field(alias="resourceTemplates")] resources: list[Resource] + server_info: Annotated[McpServerInfo | None, Field(alias="serverInfo")] = None tools: dict[str, Tool] @@ -5817,6 +5988,13 @@ class Model(BaseModel): ] = [] availability_nux: Annotated[ModelAvailabilityNux | None, Field(alias="availabilityNux")] = None default_reasoning_effort: Annotated[ReasoningEffort, Field(alias="defaultReasoningEffort")] + default_service_tier: Annotated[ + str | None, + Field( + alias="defaultServiceTier", + description="Catalog default service tier id for this model, when one is configured.", + ), + ] = None description: str display_name: Annotated[str, Field(alias="displayName")] hidden: bool @@ -5859,6 +6037,20 @@ class OverriddenMetadata(BaseModel): overriding_layer: Annotated[ConfigLayerMetadata, Field(alias="overridingLayer")] +class PermissionProfileListResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + data: list[PermissionProfileSummary] + next_cursor: Annotated[ + str | None, + Field( + alias="nextCursor", + description="Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + ), + ] = None + + class PluginSharePrincipal(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6332,6 +6524,30 @@ class ThreadGoal(BaseModel): updated_at: Annotated[int, Field(alias="updatedAt")] +class ThreadGoalGetResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + goal: ThreadGoal | None = None + + +class ThreadGoalSetParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + objective: str | None = None + status: ThreadGoalStatus | None = None + thread_id: Annotated[str, Field(alias="threadId")] + token_budget: Annotated[int | None, Field(alias="tokenBudget")] = None + + +class ThreadGoalSetResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + goal: ThreadGoal + + class ThreadGoalUpdatedNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6345,6 +6561,7 @@ class UserMessageThreadItem(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + client_id: Annotated[str | None, Field(alias="clientId")] = None content: list[UserInput] id: str type: Annotated[Literal["userMessage"], Field(title="UserMessageThreadItemType")] @@ -6536,6 +6753,55 @@ class ThreadListParams(BaseModel): ] = None +class ThreadResumeInitialTurnsPageParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + items_view: Annotated[ + TurnItemsView | None, + Field( + alias="itemsView", + description="How much item detail to include for each returned turn; defaults to summary.", + ), + ] = None + limit: Annotated[int | None, Field(description="Optional turn page size.", ge=0)] = None + sort_direction: Annotated[ + SortDirection | None, + Field( + alias="sortDirection", + description="Optional turn pagination direction; defaults to descending.", + ), + ] = None + + +class ThreadSettings(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + active_permission_profile: Annotated[ + ActivePermissionProfile | None, Field(alias="activePermissionProfile") + ] = None + approval_policy: Annotated[AskForApproval, Field(alias="approvalPolicy")] + approvals_reviewer: Annotated[ApprovalsReviewer, Field(alias="approvalsReviewer")] + collaboration_mode: Annotated[CollaborationMode, Field(alias="collaborationMode")] + cwd: AbsolutePathBuf + effort: ReasoningEffort | None = None + model: str + model_provider: Annotated[str, Field(alias="modelProvider")] + personality: Personality | None = None + sandbox_policy: Annotated[SandboxPolicy, Field(alias="sandboxPolicy")] + service_tier: Annotated[str | None, Field(alias="serviceTier")] = None + summary: ReasoningSummary | None = None + + +class ThreadSettingsUpdatedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + thread_settings: Annotated[ThreadSettings, Field(alias="threadSettings")] + + class ThreadStartParams(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6648,6 +6914,7 @@ class TurnStartParams(BaseModel): description="Override where approval requests are routed for review on this turn and subsequent turns.", ), ] = None + client_user_message_id: Annotated[str | None, Field(alias="clientUserMessageId")] = None cwd: Annotated[ str | None, Field(description="Override the working directory for this turn and subsequent turns."), @@ -6696,6 +6963,7 @@ class TurnSteerParams(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + client_user_message_id: Annotated[str | None, Field(alias="clientUserMessageId")] = None expected_turn_id: Annotated[ str, Field( @@ -6803,6 +7071,15 @@ class ThreadForkRequest(BaseModel): params: ThreadForkParams +class ThreadGoalSetRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["thread/goal/set"], Field(title="Thread/goal/setRequestMethod")] + params: ThreadGoalSetParams + + class ThreadListRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6891,6 +7168,41 @@ class ConfigValueWriteRequest(BaseModel): params: ConfigValueWriteParams +class Config(BaseModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + analytics: AnalyticsConfig | None = None + approval_policy: AskForApproval | None = None + approvals_reviewer: Annotated[ + ApprovalsReviewer | None, + Field( + description="[UNSTABLE] Optional default for where approval requests are routed for review." + ), + ] = None + compact_prompt: str | None = None + desktop: dict[str, Any] | None = None + developer_instructions: str | None = None + forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | None = None + forced_login_method: ForcedLoginMethod | None = None + instructions: str | None = None + model: str | None = None + model_auto_compact_token_limit: int | None = None + model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope | None = None + model_context_window: int | None = None + model_provider: str | None = None + model_reasoning_effort: ReasoningEffort | None = None + model_reasoning_summary: ReasoningSummary | None = None + model_verbosity: Verbosity | None = None + review_model: str | None = None + sandbox_mode: SandboxMode | None = None + sandbox_workspace_write: SandboxWorkspaceWrite | None = None + service_tier: str | None = None + tools: ToolsV2 | None = None + web_search: WebSearchMode | None = None + + class ConfigBatchWriteParams(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6913,6 +7225,15 @@ class ConfigBatchWriteParams(BaseModel): ] = None +class ConfigReadResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + config: Config + layers: list[ConfigLayer] | None = None + origins: dict[str, ConfigLayerMetadata] + + class ConfigWriteResponse(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -7115,29 +7436,6 @@ class PluginSummary(BaseModel): source: PluginSource -class ProfileV2(BaseModel): - model_config = ConfigDict( - extra="allow", - populate_by_name=True, - ) - approval_policy: AskForApproval | None = None - approvals_reviewer: Annotated[ - ApprovalsReviewer | None, - Field( - description="[UNSTABLE] Optional profile-level override for where approval requests are routed for review. If omitted, the enclosing config default is used." - ), - ] = None - chatgpt_base_url: str | None = None - model: str | None = None - model_provider: str | None = None - model_reasoning_effort: ReasoningEffort | None = None - model_reasoning_summary: ReasoningSummary | None = None - model_verbosity: Verbosity | None = None - service_tier: str | None = None - tools: ToolsV2 | None = None - web_search: WebSearchMode | None = None - - class RequestPermissionProfile(BaseModel): model_config = ConfigDict( extra="forbid", @@ -7229,6 +7527,16 @@ class ThreadGoalUpdatedServerNotification(BaseModel): params: ThreadGoalUpdatedNotification +class ThreadSettingsUpdatedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["thread/settings/updated"], Field(title="Thread/settings/updatedNotificationMethod") + ] + params: ThreadSettingsUpdatedNotification + + class ThreadTokenUsageUpdatedServerNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -7393,6 +7701,15 @@ class TurnStartedNotification(BaseModel): turn: Turn +class TurnsPage(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + backwards_cursor: Annotated[str | None, Field(alias="backwardsCursor")] = None + data: list[Turn] + next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None + + class PluginShareSaveRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -7411,51 +7728,6 @@ class ConfigBatchWriteRequest(BaseModel): params: ConfigBatchWriteParams -class Config(BaseModel): - model_config = ConfigDict( - extra="allow", - populate_by_name=True, - ) - analytics: AnalyticsConfig | None = None - approval_policy: AskForApproval | None = None - approvals_reviewer: Annotated[ - ApprovalsReviewer | None, - Field( - description="[UNSTABLE] Optional default for where approval requests are routed for review." - ), - ] = None - compact_prompt: str | None = None - desktop: dict[str, Any] | None = None - developer_instructions: str | None = None - forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | None = None - forced_login_method: ForcedLoginMethod | None = None - instructions: str | None = None - model: str | None = None - model_auto_compact_token_limit: int | None = None - model_context_window: int | None = None - model_provider: str | None = None - model_reasoning_effort: ReasoningEffort | None = None - model_reasoning_summary: ReasoningSummary | None = None - model_verbosity: Verbosity | None = None - profile: str | None = None - profiles: dict[str, ProfileV2] | None = {} - review_model: str | None = None - sandbox_mode: SandboxMode | None = None - sandbox_workspace_write: SandboxWorkspaceWrite | None = None - service_tier: str | None = None - tools: ToolsV2 | None = None - web_search: WebSearchMode | None = None - - -class ConfigReadResponse(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - config: Config - layers: list[ConfigLayer] | None = None - origins: dict[str, ConfigLayerMetadata] - - class ExternalAgentConfigDetectResponse(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -7892,6 +8164,14 @@ class ThreadRollbackResponse(BaseModel): ] +class ThreadSearchResult(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + snippet: str + thread: Thread + + class ThreadStartResponse(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -7960,6 +8240,9 @@ class ClientRequest( | ThreadArchiveRequest | ThreadUnsubscribeRequest | ThreadNameSetRequest + | ThreadGoalSetRequest + | ThreadGoalGetRequest + | ThreadGoalClearRequest | ThreadMetadataUpdateRequest | ThreadUnarchiveRequest | ThreadCompactStartRequest @@ -7971,6 +8254,7 @@ class ClientRequest( | ThreadReadRequest | ThreadInjectItemsRequest | SkillsListRequest + | SkillsExtraRootsSetRequest | HooksListRequest | MarketplaceAddRequest | MarketplaceRemoveRequest @@ -8004,6 +8288,7 @@ class ClientRequest( | ModelListRequest | ModelProviderCapabilitiesReadRequest | ExperimentalFeatureListRequest + | PermissionProfileListRequest | ExperimentalFeatureEnablementSetRequest | McpServerOauthLoginRequest | ConfigMcpServerReloadRequest @@ -8043,6 +8328,9 @@ class ClientRequest( | ThreadArchiveRequest | ThreadUnsubscribeRequest | ThreadNameSetRequest + | ThreadGoalSetRequest + | ThreadGoalGetRequest + | ThreadGoalClearRequest | ThreadMetadataUpdateRequest | ThreadUnarchiveRequest | ThreadCompactStartRequest @@ -8054,6 +8342,7 @@ class ClientRequest( | ThreadReadRequest | ThreadInjectItemsRequest | SkillsListRequest + | SkillsExtraRootsSetRequest | HooksListRequest | MarketplaceAddRequest | MarketplaceRemoveRequest @@ -8087,6 +8376,7 @@ class ClientRequest( | ModelListRequest | ModelProviderCapabilitiesReadRequest | ExperimentalFeatureListRequest + | PermissionProfileListRequest | ExperimentalFeatureEnablementSetRequest | McpServerOauthLoginRequest | ConfigMcpServerReloadRequest @@ -8158,6 +8448,7 @@ class ServerNotification( | ThreadNameUpdatedServerNotification | ThreadGoalUpdatedServerNotification | ThreadGoalClearedServerNotification + | ThreadSettingsUpdatedServerNotification | ThreadTokenUsageUpdatedServerNotification | TurnStartedServerNotification | HookStartedServerNotification @@ -8227,6 +8518,7 @@ class ServerNotification( | ThreadNameUpdatedServerNotification | ThreadGoalUpdatedServerNotification | ThreadGoalClearedServerNotification + | ThreadSettingsUpdatedServerNotification | ThreadTokenUsageUpdatedServerNotification | TurnStartedServerNotification | HookStartedServerNotification diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index a8dda48854..5a8f1275d6 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -265,8 +265,9 @@ def test_schema_normalization_only_flattens_string_literal_oneofs( "AuthMode", "InputModality", "ExperimentalFeatureStage", - "ProcessOutputStream", + "AutoCompactTokenLimitScope", "CommandExecOutputStream", + "ProcessOutputStream", ] @@ -357,10 +358,10 @@ def test_source_sdk_template_pins_published_runtime() -> None: "dependencies": pyproject["project"]["dependencies"], } == { "sdk_template_version": "0.0.0-dev", - "runtime_pin": "0.132.0", + "runtime_pin": "0.136.0", "dependencies": [ "pydantic>=2.12", - "openai-codex-cli-bin==0.132.0", + "openai-codex-cli-bin==0.136.0", ], } @@ -435,7 +436,7 @@ def test_runtime_setup_reads_independent_runtime_pin_and_release_tags() -> None: } == { "package_name": "openai-codex-cli-bin", "sdk_template_version": "0.0.0-dev", - "runtime_pin": "0.132.0", + "runtime_pin": "0.136.0", "normalized_release_version": "0.116.0a1", "release_tag": "rust-v0.116.0-alpha.1", } @@ -594,11 +595,11 @@ def test_stage_runtime_release_can_pin_wheel_platform_tag(tmp_path: Path) -> Non tmp_path / "runtime-stage", "0.116.0a1", package_archive, - platform_tag="musllinux_1_1_x86_64", + platform_tag="manylinux_2_17_x86_64", ) pyproject = (staged / "pyproject.toml").read_text() - assert 'platform-tag = "musllinux_1_1_x86_64"' in pyproject + assert 'platform-tag = "manylinux_2_17_x86_64"' in pyproject def test_stage_runtime_release_rejects_incomplete_package_layout(tmp_path: Path) -> None: @@ -650,7 +651,7 @@ def test_stage_sdk_release_preserves_reviewed_runtime_pin(tmp_path: Path) -> Non "version": "0.1.0b1", "dependencies": [ "pydantic>=2.12", - "openai-codex-cli-bin==0.132.0", + "openai-codex-cli-bin==0.136.0", ], } assert ( @@ -687,7 +688,7 @@ def test_sdk_beta_release_can_pin_stable_runtime(tmp_path: Path) -> None: ) runtime_stage = script.stage_python_runtime_package( tmp_path / "runtime-stage", - "0.132.0", + "0.136.0", package_archive, ) @@ -700,10 +701,10 @@ def test_sdk_beta_release_can_pin_stable_runtime(tmp_path: Path) -> None: "sdk_dependencies": sdk_pyproject["project"]["dependencies"], } == { "sdk_version": "0.1.0b1", - "runtime_version": "0.132.0", + "runtime_version": "0.136.0", "sdk_dependencies": [ "pydantic>=2.12", - "openai-codex-cli-bin==0.132.0", + "openai-codex-cli-bin==0.136.0", ], } @@ -762,7 +763,7 @@ def test_stage_runtime_stages_package_without_type_generation(tmp_path: Path) -> "--codex-version", "rust-v0.116.0-alpha.1", "--platform-tag", - "musllinux_1_1_x86_64", + "manylinux_2_17_x86_64", ] ) @@ -793,7 +794,7 @@ def test_stage_runtime_stages_package_without_type_generation(tmp_path: Path) -> script.run_command(args, ops) - assert calls == ["stage_runtime:0.116.0a1:musllinux_1_1_x86_64:codex-package.tar.gz"] + assert calls == ["stage_runtime:0.116.0a1:manylinux_2_17_x86_64:codex-package.tar.gz"] def test_default_runtime_is_resolved_from_installed_runtime_package( diff --git a/sdk/python/tests/test_contract_generation.py b/sdk/python/tests/test_contract_generation.py index 2c95d06a3e..a6d3d2fdc4 100644 --- a/sdk/python/tests/test_contract_generation.py +++ b/sdk/python/tests/test_contract_generation.py @@ -40,7 +40,7 @@ def test_generated_files_are_up_to_date(): # Regenerate contract artifacts via the pinned runtime package, not a local # app-server binary from the checkout or CI environment. - assert importlib.metadata.version("openai-codex-cli-bin") == "0.132.0" + assert importlib.metadata.version("openai-codex-cli-bin") == "0.136.0" env = os.environ.copy() env.pop("CODEX_EXEC_PATH", None) python_bin = str(Path(sys.executable).parent) diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock index 6d9e40e37a..9dabd34d71 100644 --- a/sdk/python/uv.lock +++ b/sdk/python/uv.lock @@ -7,7 +7,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P7D" [options.exclude-newer-package] -openai-codex-cli-bin = "2026-05-20T21:00:00Z" +openai-codex-cli-bin = "2026-06-01T20:00:00Z" [[package]] name = "annotated-types" @@ -299,7 +299,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "datamodel-code-generator", marker = "extra == 'dev'", specifier = "==0.31.2" }, - { name = "openai-codex-cli-bin", specifier = "==0.132.0" }, + { name = "openai-codex-cli-bin", specifier = "==0.136.0" }, { name = "pydantic", specifier = ">=2.12" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.8" }, @@ -308,15 +308,15 @@ provides-extras = ["dev"] [[package]] name = "openai-codex-cli-bin" -version = "0.132.0" +version = "0.136.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/a1/b92b7a1b73a83785d2e1dcd0faecd1b7f886a38cf02a30abe1c35f42f0f7/openai_codex_cli_bin-0.132.0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:1c22b51dbd679413f84f00b9d8fd4e5cf8a1c0d1c7cc8c42bcb3f9f1b33e2334", size = 89403211, upload-time = "2026-05-20T02:37:22.311Z" }, - { url = "https://files.pythonhosted.org/packages/5f/68/163272e582de55a7f460e2329281267908d75d0fbcbbbb2c6749a6329e6b/openai_codex_cli_bin-0.132.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:56217495e6635c8a5d96df820cc0da5f46cd9b6ec6f3a5f67f1607d69ef74256", size = 79058685, upload-time = "2026-05-20T02:37:27.165Z" }, - { url = "https://files.pythonhosted.org/packages/0b/18/a60c6b137e7cd3959cae16ba757f57ca5702979b0ea107a21f516ba15d98/openai_codex_cli_bin-0.132.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:09642e7578a3078bfccc82af4077b085d42b0022b529e4b5c645e0a0af3397a4", size = 78689038, upload-time = "2026-05-20T02:37:31.548Z" }, - { url = "https://files.pythonhosted.org/packages/f8/eb/1b184307a67c1006d59b61636bcfcea73a89aa95271f6516ed28dce554ca/openai_codex_cli_bin-0.132.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:85aec095f9d144d7a2d1aff39fce77b7240f42014580c35801ba74b9317aa5f7", size = 85528820, upload-time = "2026-05-20T02:37:36.559Z" }, - { url = "https://files.pythonhosted.org/packages/0e/e8/1b823a8bf7b96d1513905ad79b16a146d797f81a19a6bc350a2f95a16661/openai_codex_cli_bin-0.132.0-py3-none-win_amd64.whl", hash = "sha256:3cb5c90c55baa39bd5ddc890d2068d3e1322a57a54d1d0e623819009a205c7f5", size = 86916218, upload-time = "2026-05-20T02:37:41.886Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e6/bb8634bd4f3adaea299c95d7b03105ac417e32dd6d8bc2af5dda141d6f28/openai_codex_cli_bin-0.132.0-py3-none-win_arm64.whl", hash = "sha256:74ef93d3deef7cb83c71d19fc667defe749cdab337ec331f59a23511561b6f6a", size = 79892931, upload-time = "2026-05-20T02:37:46.828Z" }, + { url = "https://files.pythonhosted.org/packages/45/2e/09764338fcb104ebcb1ebf2490d71ab4b8339b5d6f8bdce19480adad5a99/openai_codex_cli_bin-0.136.0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:9ded24a9977221f67173f49b8c3ba3a944787b7323dbd664dd8e8ae1a5efc0aa", size = 94036262, upload-time = "2026-06-01T19:28:13.291Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/10dea458f8ead97b6449a1c05b65883ffe28724204c05e6169439576ee48/openai_codex_cli_bin-0.136.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8d194688c1afe87bf85705528cbcbdb762cb378d8c58c64162402babbd795436", size = 83713829, upload-time = "2026-06-01T19:28:17.39Z" }, + { url = "https://files.pythonhosted.org/packages/7d/83/21402a805f3312196a4f4bef0b6a45d3dee301e3db3ddeee117b4b1e612b/openai_codex_cli_bin-0.136.0-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:bdbdfeeac5bb2e744bd810cb60c55313c280d0f6ff9e93699431bcfc2040c9c2", size = 82981863, upload-time = "2026-06-01T19:28:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/33/79/1617e008381345391def39254404c0248a425739bd13c95d9fbe3e63d16f/openai_codex_cli_bin-0.136.0-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:37b353435f354c6d1bcc32e3b83e40ef9d1ef7294c8adedd229fbaedc82a6f74", size = 90187224, upload-time = "2026-06-01T19:28:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bc/a591d4464f999e0fab6abb5ba369463eee99a82e7c298e2cff257d80b849/openai_codex_cli_bin-0.136.0-py3-none-win_amd64.whl", hash = "sha256:c91ffbb1d903970e74b65958f7d1fa548fac8abdfe9b3a098f3da7f47dbb2575", size = 91137642, upload-time = "2026-06-01T19:28:28.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/f3/e8ccd644e7a87a25229022088abf4d724d3d608fcda7123f9a11515409ca/openai_codex_cli_bin-0.136.0-py3-none-win_arm64.whl", hash = "sha256:9073f7283c47ad3e585e23598ebbef554e85d280cabb637b6cb9a078c5e7086a", size = 83623904, upload-time = "2026-06-01T19:28:32.734Z" }, ] [[package]] From af18e92140f875f042325114108b8720d6836e96 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 2 Jun 2026 12:41:13 -0700 Subject: [PATCH 11/19] Use environment secrets for Azure signing (#24859) ## Summary - Move Azure Trusted Signing values out of reusable workflow-call secrets and into the `azure-artifact-signing` environment scope - Attach the Windows signing job to the `azure-artifact-signing` environment so it can resolve the signing secrets directly - Stop inheriting caller secrets for the Windows release reusable workflow ## Validation - `git diff --check -- .github/workflows/rust-release.yml .github/workflows/rust-release-windows.yml` - `ruby -e 'require "yaml"; ARGV.each { |path| YAML.load_file(path); puts "ok #{path}" }' .github/workflows/rust-release.yml .github/workflows/rust-release-windows.yml` --- .github/workflows/rust-release-windows.yml | 16 +++------------- .github/workflows/rust-release.yml | 1 - 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index fc14088ef1..0d8cc29697 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -6,19 +6,6 @@ on: release-lto: required: true type: string - secrets: - AZURE_TRUSTED_SIGNING_CLIENT_ID: - required: true - AZURE_TRUSTED_SIGNING_TENANT_ID: - required: true - AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID: - required: true - AZURE_TRUSTED_SIGNING_ENDPOINT: - required: true - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: - required: true - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: - required: true # Cargo's libgit2 transport has been flaky when fetching git dependencies with # nested submodules. Prefer the system git CLI across every Cargo invocation. @@ -164,6 +151,9 @@ jobs: - build-windows-binaries name: Build - ${{ matrix.runner }} - ${{ matrix.target }} runs-on: ${{ matrix.runs_on }} + environment: + name: azure-artifact-signing + deployment: false timeout-minutes: 90 permissions: contents: read diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 364fe78902..6b0814dae5 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -865,7 +865,6 @@ jobs: uses: ./.github/workflows/rust-release-windows.yml with: release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} - secrets: inherit argument-comment-lint-release-assets: if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} From ad355d4c9641953bb85c242c92f72f924edd4687 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 2 Jun 2026 12:42:42 -0700 Subject: [PATCH 12/19] Fix Windows running thread resume path normalization (#25509) ## Why Fixes #24944. On Windows, app-server resume could reject an active running thread when the requested session path used normal `C:\...` form and the already-running path used verbatim `\\?\C:\...` form. The paths point at the same JSONL file, but the resume stale-path guard compared raw `PathBuf`s, so desktop resume and heartbeat flows could fail with a mismatched-path error. ## What Changed - Compare requested and active rollout paths with `path_utils::paths_match_after_normalization`. - Extend the existing running-thread mismatched-path test with a Windows-only same-file resume case before the stale-path rejection. ## Verification - `just test -p codex-app-server thread_resume_rejects_mismatched_path_for_running_thread_id` --- .../request_processors/thread_processor.rs | 2 +- .../tests/suite/v2/thread_resume.rs | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 32dfb1eeb9..b26c2e4df3 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2766,7 +2766,7 @@ impl ThreadRequestProcessor { .as_ref() .or(source_thread.rollout_path.as_ref()); if let (Some(requested_path), Some(active_path)) = (params.path.as_ref(), active_path) - && requested_path != active_path + && !path_utils::paths_match_after_normalization(requested_path, active_path) { return Err(invalid_request(format!( "cannot resume running thread {existing_thread_id} with stale path: requested `{}`, active `{}`", diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index c016b38827..3073b382a5 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2410,6 +2410,36 @@ async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result ) .await??; + #[cfg(windows)] + { + let active_path = thread.path.as_ref().expect("thread should have path"); + let active_path_display = active_path.as_os_str().to_string_lossy(); + let equivalent_path = if let Some(path) = active_path_display.strip_prefix(r"\\?\UNC\") { + PathBuf::from(format!(r"\\{path}")) + } else if let Some(path) = active_path_display.strip_prefix(r"\\?\") { + PathBuf::from(path) + } else if let Some(path) = active_path_display.strip_prefix(r"\\") { + PathBuf::from(format!(r"\\?\UNC\{path}")) + } else { + PathBuf::from(format!(r"\\?\{active_path_display}")) + }; + let normalized_resume_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread_id.clone(), + path: Some(equivalent_path), + ..Default::default() + }) + .await?; + let normalized_resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(normalized_resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = + to_response::(normalized_resume_resp)?; + assert_eq!(thread.id, thread_id); + } + let stale_thread_id = Uuid::new_v4().to_string(); let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &stale_thread_id); std::fs::create_dir_all(stale_path.parent().expect("stale path parent"))?; From b794182ea7e57c9bde214bb9fd32040445a8ae38 Mon Sep 17 00:00:00 2001 From: knittel-openai <153027209+knittel-openai@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:46:14 -0700 Subject: [PATCH 13/19] Populate workspace kind on Codex turn events (#25135) ## Summary - carry `workspace_kind` from Responses API client metadata into the turn resolved analytics fact - serialize the optional value on `codex_turn_event` - cover both the turn metadata source and turn event serialization The `workspace_kind` tells us whether a thread had a project attached vs projectless. this is an indicator for who is adopting Codex for knowledge work outside of coding ## Testing - `env UV_CACHE_DIR=/private/tmp/uv-cache /private/tmp/cargo-tools/bin/just fmt` - `env PATH=/private/tmp/cargo-tools/bin:$PATH CARGO_HOME=/private/tmp/cargo-home UV_CACHE_DIR=/private/tmp/uv-cache /private/tmp/cargo-tools/bin/just test -p codex-analytics` - `env PATH=/private/tmp/cargo-tools/bin:$PATH CARGO_HOME=/private/tmp/cargo-home UV_CACHE_DIR=/private/tmp/uv-cache /private/tmp/cargo-tools/bin/just test -p codex-core turn_metadata` Paired with openai/openai#970661, which keeps forwarding the same metadata key through Responses API headers. --- codex-rs/analytics/src/analytics_client_tests.rs | 4 ++++ codex-rs/analytics/src/events.rs | 1 + codex-rs/analytics/src/facts.rs | 1 + codex-rs/analytics/src/reducer.rs | 2 ++ codex-rs/app-server/tests/suite/v2/turn_start.rs | 5 +++++ codex-rs/core/src/session/turn.rs | 1 + codex-rs/core/src/turn_metadata.rs | 9 +++++++++ codex-rs/core/src/turn_metadata_tests.rs | 3 +++ 8 files changed, 26 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0a11412efd..7ceda36a6a 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -391,6 +391,7 @@ fn sample_turn_resolved_config(thread_id: &str, turn_id: &str) -> TurnResolvedCo sandbox_network_access: true, collaboration_mode: ModeKind::Plan, personality: None, + workspace_kind: None, is_first_turn: true, } } @@ -3255,6 +3256,7 @@ fn turn_event_serializes_expected_shape() { sandbox_network_access: true, collaboration_mode: Some("plan"), personality: Some("pragmatic".to_string()), + workspace_kind: Some("projectless".to_string()), num_input_images: 2, is_first_turn: true, status: Some(TurnStatus::Completed), @@ -3320,6 +3322,7 @@ fn turn_event_serializes_expected_shape() { "sandbox_network_access": true, "collaboration_mode": "plan", "personality": "pragmatic", + "workspace_kind": "projectless", "num_input_images": 2, "is_first_turn": true, "status": "completed", @@ -3640,6 +3643,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert!(payload["event_params"].get("product_client_id").is_none()); assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["workspace_kind"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d2e7d80545..61fe75ac27 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -794,6 +794,7 @@ pub(crate) struct CodexTurnEventParams { pub(crate) sandbox_network_access: bool, pub(crate) collaboration_mode: Option<&'static str>, pub(crate) personality: Option, + pub(crate) workspace_kind: Option, pub(crate) num_input_images: usize, pub(crate) is_first_turn: bool, pub(crate) status: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 4d298ffdbf..b056108507 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -85,6 +85,7 @@ pub struct TurnResolvedConfigFact { pub sandbox_network_access: bool, pub collaboration_mode: ModeKind, pub personality: Option, + pub workspace_kind: Option, pub is_first_turn: bool, } diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 46f69c59f1..5b41178530 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -2483,6 +2483,7 @@ fn codex_turn_event_params( sandbox_network_access, collaboration_mode, personality, + workspace_kind, is_first_turn, } = resolved_config; let token_usage = turn_state.token_usage.clone(); @@ -2515,6 +2516,7 @@ fn codex_turn_event_params( sandbox_network_access, collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), personality: personality_mode(personality), + workspace_kind, num_input_images, is_first_turn, status: completed.status, diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 0c2c7b71cd..9f6a027a75 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -855,6 +855,10 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { url: "https://example.com/a.png".to_string(), detail: None, }], + responsesapi_client_metadata: Some(HashMap::from([( + "workspace_kind".to_string(), + "projectless".to_string(), + )])), ..Default::default() }) .await?; @@ -882,6 +886,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["workspace_kind"], "projectless"); assert_eq!(event["event_params"]["ephemeral"], false); assert_eq!(event["event_params"]["thread_source"], "user"); assert_eq!(event["event_params"]["initialization_mode"], "new"); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 62f8d9a497..498e65c7b9 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -640,6 +640,7 @@ async fn track_turn_resolved_config_analytics( sandbox_network_access: turn_context.network_sandbox_policy().is_enabled(), collaboration_mode: turn_context.collaboration_mode.mode, personality: turn_context.personality, + workspace_kind: turn_context.turn_metadata_state.workspace_kind(), is_first_turn, }); } diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 7296d6d3f0..e66a1fbb50 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -33,6 +33,7 @@ const MODEL_KEY: &str = "model"; const REASONING_EFFORT_KEY: &str = "reasoning_effort"; const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms"; const USER_INPUT_REQUESTED_DURING_TURN_KEY: &str = "user_input_requested_during_turn"; +const WORKSPACE_KIND_KEY: &str = "workspace_kind"; const REQUEST_KIND_KEY: &str = "request_kind"; const COMPACTION_KEY: &str = "compaction"; const WINDOW_ID_KEY: &str = "window_id"; @@ -443,6 +444,14 @@ impl TurnMetadataState { Some(responsesapi_client_metadata); } + pub(crate) fn workspace_kind(&self) -> Option { + self.responsesapi_client_metadata + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .and_then(|metadata| metadata.get(WORKSPACE_KIND_KEY).cloned()) + } + pub(crate) fn set_turn_started_at_unix_ms(&self, turn_started_at_unix_ms: i64) { *self .turn_started_at_unix_ms diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index ffbeb83d26..32a559848a 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -546,6 +546,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( state.set_responsesapi_client_metadata(HashMap::from([ ("fiber_run_id".to_string(), "fiber-123".to_string()), ("origin".to_string(), "東京".to_string()), + ("workspace_kind".to_string(), "projectless".to_string()), ("model".to_string(), "client-supplied".to_string()), ( "reasoning_effort".to_string(), @@ -580,6 +581,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( assert_eq!(json["fiber_run_id"].as_str(), Some("fiber-123")); assert_eq!(json["origin"].as_str(), Some("東京")); + assert_eq!(json["workspace_kind"].as_str(), Some("projectless")); assert_eq!(json["model"].as_str(), Some("client-supplied")); assert_eq!(json["reasoning_effort"].as_str(), Some("client-supplied")); assert_eq!(json["session_id"].as_str(), Some("session-a")); @@ -619,6 +621,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( assert_eq!(meta["model"].as_str(), Some("gpt-5.4")); assert_eq!(meta["reasoning_effort"].as_str(), Some("high")); assert!(meta.get(WINDOW_ID_KEY).is_none()); + assert_eq!(state.workspace_kind().as_deref(), Some("projectless")); } #[test] From d45cd2624848b185d10ab2f29b40fd581f407ac8 Mon Sep 17 00:00:00 2001 From: joeflorencio-openai Date: Tue, 2 Jun 2026 13:18:59 -0700 Subject: [PATCH 14/19] Switch runtime to cloud config bundle (#24622) ## Summary - Adapts the moved `codex-cloud-config` crate from the legacy cloud requirements endpoint to the new config bundle endpoint. - Switches runtime consumers from `CloudRequirementsLoader` to `CloudConfigBundleLoader` so one shared bundle supplies cloud-delivered config and requirements. - Removes the legacy cloud requirements domain loader path. ## Details This intentionally keeps `codex-cloud-config` monolithic for review lineage: the previous PR establishes the crate move, and this PR shows the behavior change against that moved implementation. A follow-up PR splits the module back into focused files. The new bundle path preserves the important cloud requirements loader semantics where intended: account-scoped signed cache, 30 minute TTL, 5 minute refresh cadence, retry/backoff, auth recovery, and fail-closed startup loading. The cached payload changes from a single requirements TOML string to the backend-delivered bundle, and validation rejects malformed config or requirements fragments before cache write/use. --- codex-rs/Cargo.lock | 2 - codex-rs/app-server-client/src/lib.rs | 14 +- codex-rs/app-server/src/config_manager.rs | 32 +- .../src/config_manager_service_tests.rs | 35 +- codex-rs/app-server/src/in_process.rs | 10 +- codex-rs/app-server/src/lib.rs | 9 +- codex-rs/app-server/src/mcp_refresh.rs | 4 +- .../src/message_processor_tracing_tests.rs | 4 +- codex-rs/app-server/src/request_processors.rs | 4 +- .../request_processors/account_processor.rs | 4 +- .../src/request_processors/config_errors.rs | 10 +- .../thread_processor_tests.rs | 46 +- .../tests/suite/conversation_summary.rs | 4 +- .../app-server/tests/suite/v2/mcp_resource.rs | 4 +- .../tests/suite/v2/remote_thread_store.rs | 4 +- .../app-server/tests/suite/v2/thread_fork.rs | 6 +- .../app-server/tests/suite/v2/thread_read.rs | 8 +- .../tests/suite/v2/thread_resume.rs | 6 +- .../app-server/tests/suite/v2/thread_start.rs | 6 +- .../tests/suite/v2/thread_unarchive.rs | 4 +- codex-rs/backend-client/src/client.rs | 18 - codex-rs/backend-client/src/lib.rs | 1 - codex-rs/backend-client/src/types.rs | 1 - codex-rs/cloud-config/Cargo.toml | 4 +- codex-rs/cloud-config/src/lib.rs | 2160 ++++++++--------- codex-rs/config/src/cloud_config_bundle.rs | 214 ++ .../config/src/cloud_config_bundle_tests.rs | 125 + codex-rs/config/src/cloud_config_layers.rs | 45 +- .../config/src/cloud_config_layers_tests.rs | 21 + codex-rs/config/src/cloud_requirements.rs | 105 - codex-rs/config/src/config_requirements.rs | 45 +- codex-rs/config/src/lib.rs | 14 +- codex-rs/config/src/loader/README.md | 26 +- codex-rs/config/src/loader/macos.rs | 47 +- codex-rs/config/src/loader/mod.rs | 298 +-- codex-rs/config/src/loader/tests.rs | 3 - codex-rs/config/src/state.rs | 7 +- codex-rs/config/src/strict_config.rs | 1 - codex-rs/config/src/test_support.rs | 80 + codex-rs/config/src/test_support_tests.rs | 25 + codex-rs/core-plugins/src/test_support.rs | 2 - .../core/src/config/config_loader_tests.rs | 496 ++-- codex-rs/core/src/config/config_tests.rs | 418 ++-- codex-rs/core/src/config/mod.rs | 14 +- codex-rs/core/src/connectors_tests.rs | 88 +- codex-rs/core/src/network_proxy_loader.rs | 2 - codex-rs/core/src/session/tests.rs | 4 +- codex-rs/core/tests/common/lib.rs | 36 +- codex-rs/core/tests/common/test_codex.rs | 19 +- codex-rs/core/tests/suite/approvals.rs | 6 +- codex-rs/core/tests/suite/hooks.rs | 2 +- codex-rs/core/tests/suite/unified_exec.rs | 2 +- codex-rs/exec/src/lib.rs | 108 +- codex-rs/exec/src/lib_tests.rs | 1 + codex-rs/hooks/src/engine/discovery.rs | 4 - codex-rs/hooks/src/engine/mod_tests.rs | 29 +- codex-rs/tui/src/debug_config.rs | 82 +- codex-rs/tui/src/lib.rs | 172 +- codex-rs/tui/src/onboarding/auth.rs | 4 +- codex-rs/tui/src/session_archive_commands.rs | 10 +- 60 files changed, 2615 insertions(+), 2340 deletions(-) create mode 100644 codex-rs/config/src/cloud_config_bundle.rs create mode 100644 codex-rs/config/src/cloud_config_bundle_tests.rs delete mode 100644 codex-rs/config/src/cloud_requirements.rs create mode 100644 codex-rs/config/src/test_support.rs create mode 100644 codex-rs/config/src/test_support_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 27b66293e1..e7bb18b5f6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2356,7 +2356,6 @@ dependencies = [ "codex-login", "codex-otel", "codex-protocol", - "codex-utils-absolute-path", "hmac 0.12.1", "pretty_assertions", "serde", @@ -2365,7 +2364,6 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "toml 0.9.11+spec-1.1.0", "tracing", ] diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 746f0d8448..c75915629c 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -43,7 +43,7 @@ use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; use codex_config::RemoteThreadConfigLoader; @@ -339,8 +339,8 @@ pub struct InProcessClientStartArgs { pub loader_overrides: LoaderOverrides, /// Whether config API paths should reject unknown config fields. pub strict_config: bool, - /// Preloaded cloud requirements provider. - pub cloud_requirements: CloudRequirementsLoader, + /// Preloaded cloud config bundle provider. + pub cloud_config_bundle: CloudConfigBundleLoader, /// Feedback sink used by app-server/core telemetry and logs. pub feedback: CodexFeedback, /// SQLite tracing layer used to flush recently emitted logs before feedback upload. @@ -406,7 +406,7 @@ impl InProcessClientStartArgs { cli_overrides: self.cli_overrides, loader_overrides: self.loader_overrides, strict_config: self.strict_config, - cloud_requirements: self.cloud_requirements, + cloud_config_bundle: self.cloud_config_bundle, thread_config_loader, feedback: self.feedback, log_db: self.log_db, @@ -1035,7 +1035,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), feedback: CodexFeedback::new(), log_db: None, state_db: Some(state_db), @@ -2199,7 +2199,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), feedback: CodexFeedback::new(), log_db: None, state_db: None, @@ -2240,7 +2240,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), feedback: CodexFeedback::new(), log_db: None, state_db: None, diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 11633342b3..fae113154b 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -1,6 +1,6 @@ use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::cloud_requirements_loader; -use codex_config::CloudRequirementsLoader; +use codex_cloud_config::cloud_config_bundle_loader; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLayerStack; use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; @@ -31,7 +31,7 @@ pub(crate) struct ConfigManager { runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: Arc>, + cloud_config_bundle: Arc>, arg0_paths: Arg0DispatchPaths, thread_config_loader: Arc>>, } @@ -42,7 +42,7 @@ impl ConfigManager { cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, arg0_paths: Arg0DispatchPaths, thread_config_loader: Arc, ) -> Self { @@ -52,7 +52,7 @@ impl ConfigManager { runtime_feature_enablement: Arc::new(RwLock::new(BTreeMap::new())), loader_overrides, strict_config, - cloud_requirements: Arc::new(RwLock::new(cloud_requirements)), + cloud_config_bundle: Arc::new(RwLock::new(cloud_config_bundle)), arg0_paths, thread_config_loader: Arc::new(RwLock::new(thread_config_loader)), } @@ -73,8 +73,8 @@ impl ConfigManager { .unwrap_or_default() } - pub(crate) fn current_cloud_requirements(&self) -> CloudRequirementsLoader { - self.cloud_requirements + pub(crate) fn current_cloud_config_bundle(&self) -> CloudConfigBundleLoader { + self.cloud_config_bundle .read() .map(|guard| guard.clone()) .unwrap_or_default() @@ -90,17 +90,17 @@ impl ConfigManager { Ok(()) } - pub(crate) fn replace_cloud_requirements_loader( + pub(crate) fn replace_cloud_config_bundle_loader( &self, auth_manager: Arc, chatgpt_base_url: String, ) { let loader = - cloud_requirements_loader(auth_manager, chatgpt_base_url, self.codex_home.clone()); - if let Ok(mut guard) = self.cloud_requirements.write() { + cloud_config_bundle_loader(auth_manager, chatgpt_base_url, self.codex_home.clone()); + if let Ok(mut guard) = self.cloud_config_bundle.write() { *guard = loader; } else { - warn!("failed to update cloud requirements loader"); + warn!("failed to update cloud config bundle loader"); } } @@ -245,7 +245,7 @@ impl ConfigManager { .strict_config(self.strict_config) .harness_overrides(typesafe_overrides) .fallback_cwd(fallback_cwd) - .cloud_requirements(self.current_cloud_requirements()) + .cloud_config_bundle(self.current_cloud_config_bundle()) .thread_config_loader(self.current_thread_config_loader()) .build() .await?; @@ -274,8 +274,8 @@ impl ConfigManager { codex_config::ConfigLoadOptions { loader_overrides: self.loader_overrides.clone(), strict_config: self.strict_config, + cloud_config_bundle: self.current_cloud_config_bundle(), }, - self.current_cloud_requirements(), thread_config_loader.as_ref(), ) .await @@ -303,14 +303,14 @@ impl ConfigManager { codex_home: PathBuf, cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, ) -> Self { Self::new( codex_home, cli_overrides, loader_overrides, /*strict_config*/ false, - cloud_requirements, + cloud_config_bundle, Arg0DispatchPaths::default(), Arc::new(codex_config::NoopThreadConfigLoader), ) @@ -322,7 +322,7 @@ impl ConfigManager { codex_home, Vec::new(), LoaderOverrides::without_managed_config_for_tests(), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ) } } diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index da2437ca17..8deae8b065 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -4,12 +4,11 @@ use codex_app_server_protocol::AppConfig; use codex_app_server_protocol::AppToolApproval; use codex_app_server_protocol::AppsConfig; use codex_app_server_protocol::AskForApproval; -use codex_config::CloudRequirementsLoader; -use codex_config::FeatureRequirementsToml; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; +use codex_config::test_support::CloudConfigBundleFixture; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; -use std::collections::BTreeMap; use tempfile::tempdir; #[test] @@ -356,7 +355,7 @@ async fn read_includes_origins_and_layers() { tmp.path().to_path_buf(), vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let response = service @@ -435,7 +434,7 @@ writable_roots = ["~/code"] tmp.path().to_path_buf(), vec![], loader_overrides, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let response = service @@ -475,7 +474,7 @@ async fn write_value_reports_override() { tmp.path().to_path_buf(), vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let result = service @@ -578,7 +577,7 @@ async fn write_value_defaults_to_selected_user_config_path() { tmp.path().to_path_buf(), vec![], loader_overrides, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); service .write_value(ConfigValueWriteParams { @@ -618,7 +617,7 @@ async fn load_default_config_preserves_selected_user_config_path_after_load_erro tmp.path().to_path_buf(), vec![], loader_overrides, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); service @@ -648,7 +647,7 @@ async fn invalid_user_value_rejected_even_if_overridden_by_managed() { tmp.path().to_path_buf(), vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let error = service @@ -708,14 +707,12 @@ async fn write_value_rejects_feature_requirement_conflict() { tmp.path().to_path_buf(), vec![], LoaderOverrides::without_managed_config_for_tests(), - CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - feature_requirements: Some(FeatureRequirementsToml { - entries: BTreeMap::from([("personality".to_string(), true)]), - }), - ..Default::default() - })) - }), + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +personality = true +"#, + ), ); let error = service @@ -765,7 +762,7 @@ async fn read_reports_managed_overrides_user_and_session_flags() { tmp.path().to_path_buf(), cli_overrides, LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let response = service @@ -821,7 +818,7 @@ async fn write_value_reports_managed_override() { tmp.path().to_path_buf(), vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let result = service diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index f7072d0fa1..448a389851 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -77,7 +77,7 @@ use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; use codex_core::config::Config; @@ -124,8 +124,8 @@ pub struct InProcessStartArgs { pub loader_overrides: LoaderOverrides, /// Whether config API paths should reject unknown config fields. pub strict_config: bool, - /// Preloaded cloud requirements provider. - pub cloud_requirements: CloudRequirementsLoader, + /// Preloaded cloud config bundle provider. + pub cloud_config_bundle: CloudConfigBundleLoader, /// Loader used to fetch typed thread config sources before a thread starts. pub thread_config_loader: Arc, /// Feedback sink used by app-server/core telemetry and logs. @@ -415,7 +415,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult { - warn!(error = %err, "Failed to preload config for cloud requirements"); - // TODO(gt): Make cloud requirements preload failures blocking once we can fail-closed. + warn!(error = %err, "Failed to preload config for cloud config bundle"); + // TODO: Decide whether bootstrap config preload failures should block startup. + // If this fails, we cannot install cloud/thread config loaders, so non-strict + // startup may continue without managed cloud config. } }; let mut config_warnings = Vec::new(); diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index 4ddfa34735..0996c409bc 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -103,7 +103,7 @@ mod tests { use crate::extensions::thread_extensions; use async_trait::async_trait; use codex_arg0::Arg0DispatchPaths; - use codex_config::CloudRequirementsLoader; + use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::ThreadConfigContext; use codex_config::ThreadConfigLoadError; @@ -213,7 +213,7 @@ mod tests { Vec::new(), LoaderOverrides::without_managed_config_for_tests(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), Arg0DispatchPaths::default(), loader.clone(), ); diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 94b5a1af27..771b1fd128 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -23,7 +23,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -240,7 +240,7 @@ async fn build_test_processor( Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), Arg0DispatchPaths::default(), Arc::new(codex_config::NoopThreadConfigLoader), ); diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 5d8cb4d794..36008ce0b9 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -266,8 +266,8 @@ use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCre use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; use codex_chatgpt::workspace_settings; -use codex_config::CloudRequirementsLoadError; -use codex_config::CloudRequirementsLoadErrorCode; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoadErrorCode; use codex_config::ConfigLayerStack; use codex_config::loader::project_trust_key; use codex_config::types::McpServerTransportConfig; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 17d78fb20b..8ebfc037e1 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -586,7 +586,7 @@ impl AccountRequestProcessor { ) .map_err(|err| internal_error(format!("failed to set external auth: {err}")))?; self.auth_manager.reload().await; - self.config_manager.replace_cloud_requirements_loader( + self.config_manager.replace_cloud_config_bundle_loader( self.auth_manager.clone(), self.config.chatgpt_base_url.clone(), ); @@ -645,7 +645,7 @@ impl AccountRequestProcessor { let auth_manager = thread_manager.auth_manager(); auth_manager.reload().await; config_manager - .replace_cloud_requirements_loader(auth_manager.clone(), chatgpt_base_url); + .replace_cloud_config_bundle_loader(auth_manager.clone(), chatgpt_base_url); config_manager .sync_default_client_residency_requirement() .await; diff --git a/codex-rs/app-server/src/request_processors/config_errors.rs b/codex-rs/app-server/src/request_processors/config_errors.rs index 63fe2b3d2c..7e93b63ede 100644 --- a/codex-rs/app-server/src/request_processors/config_errors.rs +++ b/codex-rs/app-server/src/request_processors/config_errors.rs @@ -1,11 +1,11 @@ use super::*; -fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequirementsLoadError> { +fn cloud_config_bundle_load_error(err: &std::io::Error) -> Option<&CloudConfigBundleLoadError> { let mut current: Option<&(dyn std::error::Error + 'static)> = err .get_ref() .map(|source| source as &(dyn std::error::Error + 'static)); while let Some(source) = current { - if let Some(cloud_error) = source.downcast_ref::() { + if let Some(cloud_error) = source.downcast_ref::() { return Some(cloud_error); } current = source.source(); @@ -14,16 +14,16 @@ fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequireme } pub(super) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError { - let data = cloud_requirements_load_error(err).map(|cloud_error| { + let data = cloud_config_bundle_load_error(err).map(|cloud_error| { let mut data = serde_json::json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": format!("{:?}", cloud_error.code()), "detail": cloud_error.to_string(), }); if let Some(status_code) = cloud_error.status_code() { data["statusCode"] = serde_json::json!(status_code); } - if cloud_error.code() == CloudRequirementsLoadErrorCode::Auth { + if cloud_error.code() == CloudConfigBundleLoadErrorCode::Auth { data["action"] = serde_json::json!("relogin"); } data diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 8689193ced..6be022aa6f 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -54,7 +54,7 @@ mod thread_processor_behavior_tests { use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ToolRequestUserInputParams; - use codex_config::CloudRequirementsLoader; + use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::SessionThreadConfig; use codex_config::StaticThreadConfigLoader; @@ -508,9 +508,9 @@ mod thread_processor_behavior_tests { } #[test] - fn config_load_error_marks_cloud_requirements_failures_for_relogin() { - let err = std::io::Error::other(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Auth, + fn config_load_error_marks_cloud_config_bundle_failures_for_relogin() { + let err = std::io::Error::other(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, Some(401), "Your authentication session could not be refreshed automatically. Please log out and sign in again.", )); @@ -520,7 +520,7 @@ mod thread_processor_behavior_tests { assert_eq!( error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "Auth", "action": "relogin", "statusCode": 401, @@ -535,7 +535,7 @@ mod thread_processor_behavior_tests { } #[test] - fn config_load_error_leaves_non_cloud_requirements_failures_unmarked() { + fn config_load_error_leaves_non_cloud_config_bundle_failures_unmarked() { let err = std::io::Error::other("required MCP servers failed to initialize"); let error = config_load_error(&err); @@ -549,11 +549,11 @@ mod thread_processor_behavior_tests { } #[test] - fn config_load_error_marks_non_auth_cloud_requirements_failures_without_relogin() { - let err = std::io::Error::other(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::RequestFailed, + fn config_load_error_marks_non_auth_cloud_config_bundle_failures_without_relogin() { + let err = std::io::Error::other(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::RequestFailed, /*status_code*/ None, - "Failed to load cloud requirements (workspace-managed policies).", + "Failed to load cloud config bundle (workspace-managed policies).", )); let error = config_load_error(&err); @@ -561,9 +561,29 @@ mod thread_processor_behavior_tests { assert_eq!( error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "RequestFailed", - "detail": "Failed to load cloud requirements (workspace-managed policies).", + "detail": "Failed to load cloud config bundle (workspace-managed policies).", + })) + ); + } + + #[test] + fn config_load_error_marks_invalid_cloud_config_bundle_failures_without_relogin() { + let err = std::io::Error::other(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::InvalidBundle, + /*status_code*/ None, + "invalid cloud config bundle: invalid cloud config fragment Base policy (cfg_123)", + )); + + let error = config_load_error(&err); + + assert_eq!( + error.data, + Some(json!({ + "reason": "cloudConfigBundle", + "errorCode": "InvalidBundle", + "detail": "invalid cloud config bundle: invalid cloud config fragment Base policy (cfg_123)", })) ); } @@ -595,7 +615,7 @@ mod thread_processor_behavior_tests { Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), Arg0DispatchPaths::default(), Arc::new(StaticThreadConfigLoader::new(vec![ ThreadConfigSource::Session(SessionThreadConfig { diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index d54f5edc51..05e3ad1c77 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -15,7 +15,7 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::config::ConfigBuilder; use codex_exec_server::EnvironmentManager; @@ -150,7 +150,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index 37b7a10d9b..d8ecb88996 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -20,7 +20,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::ConfigBuilder; @@ -201,7 +201,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index 158ebb25d1..016e4c3df8 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -34,7 +34,7 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::UserInput as V2UserInput; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; use codex_core::config::ConfigBuilder; @@ -76,7 +76,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index afbe62b321..afb1ca0ed1 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -510,10 +510,10 @@ async fn thread_fork_with_empty_path_uses_thread_id() -> Result<()> { } #[tokio::test] -async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { +async fn thread_fork_surfaces_cloud_config_bundle_load_errors() -> Result<()> { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/backend-api/wham/config/requirements")) + .and(path("/backend-api/wham/config/bundle")) .respond_with( ResponseTemplate::new(401) .insert_header("content-type", "text/html") @@ -594,7 +594,7 @@ async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { assert_eq!( fork_err.error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "Auth", "action": "relogin", "statusCode": 401, diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 17c55a74a3..0a99d30ada 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -41,7 +41,7 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::ARCHIVED_SESSIONS_SUBDIR; use codex_core::config::ConfigBuilder; @@ -376,7 +376,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result< cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, @@ -442,7 +442,7 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, @@ -528,7 +528,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 3073b382a5..d18a405164 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -3121,10 +3121,10 @@ async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> R } #[tokio::test] -async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { +async fn thread_resume_surfaces_cloud_config_bundle_load_errors() -> Result<()> { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/backend-api/wham/config/requirements")) + .and(path("/backend-api/wham/config/bundle")) .respond_with( ResponseTemplate::new(401) .insert_header("content-type", "text/html") @@ -3201,7 +3201,7 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { assert_eq!( err.error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "Auth", "action": "relogin", "statusCode": 401, diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 3c4a0dba5e..7a7ed5522a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -718,10 +718,10 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result< } #[tokio::test] -async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> { +async fn thread_start_surfaces_cloud_config_bundle_load_errors() -> Result<()> { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/backend-api/wham/config/requirements")) + .and(path("/backend-api/wham/config/bundle")) .respond_with( ResponseTemplate::new(401) .insert_header("content-type", "text/html") @@ -788,7 +788,7 @@ async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> { assert_eq!( err.error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "Auth", "action": "relogin", "statusCode": 401, diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 09c17bb79f..84fffd19ba 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -22,7 +22,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::config::ConfigBuilder; use codex_core::find_archived_thread_path_by_id_str; @@ -247,7 +247,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 159dba8b84..d9bdc9654e 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,6 +1,5 @@ use crate::types::CodeTaskDetailsResponse; use crate::types::ConfigBundleResponse; -use crate::types::ConfigFileResponse; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; use crate::types::RateLimitStatusPayload; @@ -393,23 +392,6 @@ impl Client { self.decode_json::(&url, &ct, &body) } - /// Fetch the managed requirements file from codex-backend. - /// - /// `GET /api/codex/config/requirements` (Codex API style) or - /// `GET /wham/config/requirements` (ChatGPT backend-api style). - pub async fn get_config_requirements_file( - &self, - ) -> std::result::Result { - let url = match self.path_style { - PathStyle::CodexApi => format!("{}/api/codex/config/requirements", self.base_url), - PathStyle::ChatGptApi => format!("{}/wham/config/requirements", self.base_url), - }; - let req = self.http.get(&url).headers(self.headers()); - let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; - self.decode_json::(&url, &ct, &body) - .map_err(RequestError::from) - } - /// Fetch the selected cloud-managed config bundle from codex-backend. /// /// `GET /api/codex/config/bundle` (Codex API style) or diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 00b5bd3697..250aac3d73 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -7,7 +7,6 @@ pub use client::RequestError; pub use types::CodeTaskDetailsResponse; pub use types::CodeTaskDetailsResponseExt; pub use types::ConfigBundleResponse; -pub use types::ConfigFileResponse; pub use types::DeliveredConfigToml; pub use types::DeliveredRequirementsToml; pub use types::DeliveredTomlFragment; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 38d9c6838f..40daba2ce5 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -1,5 +1,4 @@ pub use codex_backend_openapi_models::models::ConfigBundleResponse; -pub use codex_backend_openapi_models::models::ConfigFileResponse; pub use codex_backend_openapi_models::models::CreditStatusDetails; pub use codex_backend_openapi_models::models::DeliveredConfigToml; pub use codex_backend_openapi_models::models::DeliveredRequirementsToml; diff --git a/codex-rs/cloud-config/Cargo.toml b/codex-rs/cloud-config/Cargo.toml index aeba70a489..b9afaee105 100644 --- a/codex-rs/cloud-config/Cargo.toml +++ b/codex-rs/cloud-config/Cargo.toml @@ -17,14 +17,12 @@ codex-core = { workspace = true } codex-login = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } -codex-utils-absolute-path = { workspace = true } hmac = "0.12.1" serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["fs", "sync", "time"] } -toml = { workspace = true } +tokio = { workspace = true, features = ["fs", "rt", "sync", "time"] } tracing = { workspace = true } [dev-dependencies] diff --git a/codex-rs/cloud-config/src/lib.rs b/codex-rs/cloud-config/src/lib.rs index 0beb91780c..72d2b4d1cf 100644 --- a/codex-rs/cloud-config/src/lib.rs +++ b/codex-rs/cloud-config/src/lib.rs @@ -1,12 +1,7 @@ -//! Cloud-hosted config requirements for Codex. +//! Cloud-hosted configuration data for Codex. //! -//! This crate fetches `requirements.toml` data from the backend as an alternative to loading it -//! from the local filesystem. It only applies to Business (aka Enterprise CBP) or Enterprise ChatGPT -//! customers. -//! -//! Fetching fails closed for eligible ChatGPT Business and Enterprise accounts. When cloud -//! requirements cannot be loaded for those accounts, Codex fails configuration loading rather than -//! continuing without them. +//! This crate owns transport, caching, and refresh behavior for cloud-delivered +//! config data. Parsing and composition remain in `codex-config`. use async_trait::async_trait; use base64::Engine; @@ -15,23 +10,30 @@ use chrono::DateTime; use chrono::Duration as ChronoDuration; use chrono::Utc; use codex_backend_client::Client as BackendClient; -use codex_config::CloudRequirementsLoadError; -use codex_config::CloudRequirementsLoadErrorCode; -use codex_config::CloudRequirementsLoader; -use codex_config::ConfigRequirementsToml; +use codex_backend_client::ConfigBundleResponse; +use codex_backend_client::DeliveredTomlFragment; +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigBundle; +use codex_config::CloudConfigBundleLayers; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoadErrorCode; +use codex_config::CloudConfigBundleLoader; +use codex_config::CloudConfigFragment; +use codex_config::CloudConfigTomlBundle; +use codex_config::CloudRequirementsFragment; +use codex_config::CloudRequirementsTomlBundle; +use codex_config::compose_requirements; use codex_config::types::AuthCredentialsStoreMode; use codex_core::util::backoff; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::RefreshTokenError; use codex_protocol::account::PlanType; -use codex_utils_absolute_path::AbsolutePathBufGuard; use hmac::Hmac; use hmac::Mac; use serde::Deserialize; use serde::Serialize; use sha2::Sha256; -use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; @@ -46,24 +48,21 @@ use tokio::time::timeout; const CLOUD_REQUIREMENTS_TIMEOUT: Duration = Duration::from_secs(15); const CLOUD_REQUIREMENTS_MAX_ATTEMPTS: usize = 5; -const CLOUD_REQUIREMENTS_CACHE_FILENAME: &str = "cloud-requirements-cache.json"; +const CLOUD_CONFIG_BUNDLE_CACHE_VERSION: u32 = 1; +const CLOUD_REQUIREMENTS_CACHE_FILENAME: &str = "cloud-config-bundle-cache.json"; const CLOUD_REQUIREMENTS_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); const CLOUD_REQUIREMENTS_CACHE_TTL: Duration = Duration::from_secs(30 * 60); -const CLOUD_REQUIREMENTS_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_requirements.fetch_attempt"; -const CLOUD_REQUIREMENTS_FETCH_FINAL_METRIC: &str = "codex.cloud_requirements.fetch_final"; -const CLOUD_REQUIREMENTS_LOAD_METRIC: &str = "codex.cloud_requirements.load"; +const CLOUD_REQUIREMENTS_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_config_bundle.fetch_attempt"; +const CLOUD_REQUIREMENTS_FETCH_FINAL_METRIC: &str = "codex.cloud_config_bundle.fetch_final"; +const CLOUD_REQUIREMENTS_LOAD_METRIC: &str = "codex.cloud_config_bundle.load"; const CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE: &str = - "Failed to load cloud requirements (workspace-managed policies)."; -const CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE: &str = concat!( - "Cloud requirements (workspace-managed policies) are invalid and could not be parsed. ", - "Please contact your workspace admin." -); + "Failed to load cloud config bundle (workspace-managed policies)."; const CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!( "Your authentication session could not be refreshed automatically. ", "Please log out and sign in again." ); const CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY: &[u8] = - b"codex-cloud-requirements-cache-v3-064f8542-75b4-494c-a294-97d3ce597271"; + b"codex-cloud-config-bundle-cache-v1-6160ae70-bcfd-4ca8-a99b-40f73b3b072e"; const CLOUD_REQUIREMENTS_CACHE_READ_HMAC_KEYS: &[&[u8]] = &[CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY]; @@ -100,54 +99,50 @@ enum FetchAttemptError { #[derive(Clone, Debug, Eq, Error, PartialEq)] enum CacheLoadStatus { - #[error("Skipping cloud requirements cache read because auth identity is incomplete.")] + #[error("Skipping cloud config bundle cache read because auth identity is incomplete.")] AuthIdentityIncomplete, - #[error("Cloud requirements cache file not found.")] + #[error("Cloud config bundle cache file not found.")] CacheFileNotFound, - #[error("Failed to read cloud requirements cache: {0}.")] + #[error("Failed to read cloud config bundle cache: {0}.")] CacheReadFailed(String), - #[error("Failed to parse cloud requirements cache: {0}.")] + #[error("Failed to parse cloud config bundle cache: {0}.")] CacheParseFailed(String), - #[error("Cloud requirements cache failed signature verification.")] + #[error("Cloud config bundle cache failed signature verification.")] CacheSignatureInvalid, - #[error("Ignoring cloud requirements cache because cached identity is incomplete.")] + #[error("Ignoring cloud config bundle cache because cached identity is incomplete.")] CacheIdentityIncomplete, - #[error("Ignoring cloud requirements cache for different auth identity.")] + #[error("Ignoring cloud config bundle cache for different auth identity.")] CacheIdentityMismatch, - #[error("Cloud requirements cache expired.")] + #[error("Ignoring cloud config bundle cache with unsupported version {0}.")] + CacheVersionUnsupported(u32), + #[error("Cloud config bundle cache expired.")] CacheExpired, + #[error("Ignoring cloud config bundle cache because the cached bundle is invalid.")] + CacheInvalidBundle, } #[derive(Debug, Error)] enum CloudRequirementsError { - #[error("failed to write cloud requirements cache")] + #[error("failed to write cloud config bundle cache")] CacheWrite, } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] struct CloudRequirementsCacheFile { signed_payload: CloudRequirementsCacheSignedPayload, signature: String, } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] struct CloudRequirementsCacheSignedPayload { + version: u32, cached_at: DateTime, expires_at: DateTime, chatgpt_user_id: Option, account_id: Option, - contents: Option, + bundle: CloudConfigBundle, } -impl CloudRequirementsCacheSignedPayload { - fn requirements(&self, requirements_base_dir: &Path) -> Option { - self.contents.as_deref().and_then(|contents| { - parse_cloud_requirements(contents, requirements_base_dir) - .ok() - .flatten() - }) - } -} fn sign_cache_payload(payload_bytes: &[u8]) -> Option { let mut mac = HmacSha256::new_from_slice(CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY).ok()?; mac.update(payload_bytes); @@ -191,19 +186,28 @@ fn cloud_requirements_eligible_auth(auth: &CodexAuth) -> bool { && (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) } +fn optional_bundle(bundle: CloudConfigBundle) -> Option { + if bundle.is_empty() { + None + } else { + Some(bundle) + } +} + fn cache_payload_bytes(payload: &CloudRequirementsCacheSignedPayload) -> Option> { serde_json::to_vec(&payload).ok() } +/// Retrieves one cloud config bundle from the backend. +/// +/// Implementations should return the backend-selected bundle exactly as delivered and leave +/// validation, caching, and config/requirements parsing decisions to the service layer. #[async_trait] trait RequirementsFetcher: Send + Sync { - /// Returns `Ok(None)` when there are no cloud requirements for the account. - /// - /// Returning `Err` indicates cloud requirements could not be fetched. async fn fetch_requirements( &self, auth: &CodexAuth, - ) -> Result, FetchAttemptError>; + ) -> Result; } struct BackendRequirementsFetcher { @@ -221,20 +225,22 @@ impl RequirementsFetcher for BackendRequirementsFetcher { async fn fetch_requirements( &self, auth: &CodexAuth, - ) -> Result, FetchAttemptError> { + ) -> Result { let client = BackendClient::from_auth(self.base_url.clone(), auth) .inspect_err(|err| { tracing::warn!( error = %err, - "Failed to construct backend client for cloud requirements" + "Failed to construct backend client for cloud config bundle" ); }) .map_err(|_| FetchAttemptError::Retryable(RetryableFailureKind::BackendClientInit))?; let response = client - .get_config_requirements_file() + .get_config_bundle() .await - .inspect_err(|err| tracing::warn!(error = %err, "Failed to fetch cloud requirements")) + .inspect_err(|err| { + tracing::warn!(error = %err, "Failed to fetch cloud config bundle"); + }) .map_err(|err| { let status_code = err.status().map(|status| status.as_u16()); if err.is_unauthorized() { @@ -247,14 +253,55 @@ impl RequirementsFetcher for BackendRequirementsFetcher { } })?; - let Some(contents) = response.contents else { - tracing::info!( - "Cloud requirements response missing contents; treating as no requirements" - ); - return Ok(None); - }; + Ok(bundle_from_response(response)) + } +} - Ok(Some(contents)) +fn bundle_from_response(response: ConfigBundleResponse) -> CloudConfigBundle { + let config_toml = response + .config_toml + .flatten() + .map(|config_toml| *config_toml) + .and_then(|config_toml| config_toml.enterprise_managed.flatten()) + .unwrap_or_default() + .into_iter() + .map(config_fragment_from_delivered) + .collect(); + let requirements_toml = response + .requirements_toml + .flatten() + .map(|requirements_toml| *requirements_toml) + .and_then(|requirements_toml| requirements_toml.enterprise_managed.flatten()) + .unwrap_or_default() + .into_iter() + .map(requirements_fragment_from_delivered) + .collect(); + + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: config_toml, + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: requirements_toml, + }, + } +} + +fn config_fragment_from_delivered(fragment: DeliveredTomlFragment) -> CloudConfigFragment { + CloudConfigFragment { + id: fragment.id, + name: fragment.name, + contents: fragment.contents, + } +} + +fn requirements_fragment_from_delivered( + fragment: DeliveredTomlFragment, +) -> CloudRequirementsFragment { + CloudRequirementsFragment { + id: fragment.id, + name: fragment.name, + contents: fragment.contents, } } @@ -262,8 +309,8 @@ impl RequirementsFetcher for BackendRequirementsFetcher { struct CloudRequirementsService { auth_manager: Arc, fetcher: Arc, - requirements_base_dir: PathBuf, - cache_path: PathBuf, + cache_path: AbsolutePathBuf, + codex_home: AbsolutePathBuf, timeout: Duration, } @@ -274,90 +321,102 @@ impl CloudRequirementsService { codex_home: PathBuf, timeout: Duration, ) -> Self { + let codex_home = AbsolutePathBuf::resolve_path_against_base(codex_home, "/"); + let cache_path = codex_home.join(CLOUD_REQUIREMENTS_CACHE_FILENAME); Self { auth_manager, fetcher, - requirements_base_dir: codex_home.clone(), - cache_path: codex_home.join(CLOUD_REQUIREMENTS_CACHE_FILENAME), + cache_path, + codex_home, timeout, } } async fn fetch_with_timeout( &self, - ) -> Result, CloudRequirementsLoadError> { + ) -> Result, CloudConfigBundleLoadError> { let _timer = - codex_otel::start_global_timer("codex.cloud_requirements.fetch.duration_ms", &[]); + codex_otel::start_global_timer("codex.cloud_config_bundle.fetch.duration_ms", &[]); let started_at = Instant::now(); - let fetch_result = timeout(self.timeout, self.fetch()) + let load_result = timeout(self.timeout, self.fetch()) .await .inspect_err(|_| { let message = format!( - "Timed out waiting for cloud requirements after {}s", + "Timed out waiting for cloud config bundle after {}s", self.timeout.as_secs() ); tracing::error!("{message}"); - emit_load_metric("startup", "error"); + emit_load_metric("startup", "error", /*bundle*/ None); }) .map_err(|_| { - CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Timeout, + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Timeout, /*status_code*/ None, format!( - "timed out waiting for cloud requirements after {}s", + "timed out waiting for cloud config bundle after {}s", self.timeout.as_secs() ), ) })?; - let result = match fetch_result { + let result = match load_result { Ok(result) => result, Err(err) => { - emit_load_metric("startup", "error"); + emit_load_metric("startup", "error", /*bundle*/ None); return Err(err); } }; match result.as_ref() { - Some(requirements) => { + Some(bundle) => { tracing::info!( elapsed_ms = started_at.elapsed().as_millis(), - requirements = ?requirements, - "Cloud requirements load completed" + config_fragments = bundle.config_toml.enterprise_managed.len(), + requirements_fragments = bundle.requirements_toml.enterprise_managed.len(), + "Cloud config bundle load completed" ); - emit_load_metric("startup", "success"); + emit_load_metric("startup", "success", Some(bundle)); } None => { tracing::info!( elapsed_ms = started_at.elapsed().as_millis(), - "Cloud requirements load completed (none)" + "Cloud config bundle load completed (none)" ); - emit_load_metric("startup", "success"); + emit_load_metric("startup", "success", /*bundle*/ None); } } Ok(result) } - async fn fetch(&self) -> Result, CloudRequirementsLoadError> { + async fn fetch(&self) -> Result, CloudConfigBundleLoadError> { let Some(auth) = self.auth_manager.auth().await else { return Ok(None); }; if !cloud_requirements_eligible_auth(&auth) { return Ok(None); } - let (chatgpt_user_id, account_id) = auth_identity(&auth); + let (chatgpt_user_id, account_id) = auth_identity(&auth); match self .load_cache(chatgpt_user_id.as_deref(), account_id.as_deref()) .await { Ok(signed_payload) => { - tracing::info!( - path = %self.cache_path.display(), - "Using cached cloud requirements" - ); - return Ok(signed_payload.requirements(&self.requirements_base_dir)); + if let Err(err) = validate_bundle(&signed_payload.bundle, &self.codex_home) { + tracing::warn!( + path = %self.cache_path.display(), + error = %err, + "Ignoring invalid cached cloud config bundle" + ); + self.log_cache_load_status(&CacheLoadStatus::CacheInvalidBundle); + } else { + tracing::info!( + path = %self.cache_path.display(), + "Using cached cloud config bundle" + ); + return Ok(optional_bundle(signed_payload.bundle)); + } } Err(cache_load_status) => { self.log_cache_load_status(&cache_load_status); @@ -371,18 +430,18 @@ impl CloudRequirementsService { &self, mut auth: CodexAuth, trigger: &'static str, - ) -> Result, CloudRequirementsLoadError> { + ) -> Result, CloudConfigBundleLoadError> { let mut attempt = 1; let mut last_status_code: Option = None; let mut auth_recovery = self.auth_manager.unauthorized_recovery(); while attempt <= CLOUD_REQUIREMENTS_MAX_ATTEMPTS { - let contents = match self.fetcher.fetch_requirements(&auth).await { - Ok(contents) => { + let bundle = match self.fetcher.fetch_requirements(&auth).await { + Ok(bundle) => { emit_fetch_attempt_metric( trigger, attempt, "success", /*status_code*/ None, ); - contents + bundle } Err(FetchAttemptError::Retryable(status)) => { let status_code = status.status_code(); @@ -393,7 +452,7 @@ impl CloudRequirementsService { status = ?status, attempt, max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Failed to fetch cloud requirements; retrying" + "Failed to fetch cloud config bundle; retrying" ); sleep(backoff(attempt as u64)).await; } @@ -410,13 +469,13 @@ impl CloudRequirementsService { tracing::warn!( attempt, max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Cloud requirements request was unauthorized; attempting auth recovery" + "Cloud config bundle request was unauthorized; attempting auth recovery" ); match auth_recovery.next().await { Ok(_) => { let Some(refreshed_auth) = self.auth_manager.auth().await else { tracing::error!( - "Auth recovery succeeded but no auth is available for cloud requirements" + "Auth recovery succeeded but no auth is available for cloud config bundle" ); emit_fetch_final_metric( trigger, @@ -424,9 +483,10 @@ impl CloudRequirementsService { "auth_recovery_missing_auth", attempt, status_code, + /*bundle*/ None, ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Auth, + return Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, status_code, CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, )); @@ -437,7 +497,7 @@ impl CloudRequirementsService { Err(RefreshTokenError::Permanent(failed)) => { tracing::warn!( error = %failed, - "Failed to recover from unauthorized cloud requirements request" + "Failed to recover from unauthorized cloud config bundle request" ); emit_fetch_final_metric( trigger, @@ -445,9 +505,10 @@ impl CloudRequirementsService { "auth_recovery_unrecoverable", attempt, status_code, + /*bundle*/ None, ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Auth, + return Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, status_code, failed.message, )); @@ -458,7 +519,7 @@ impl CloudRequirementsService { error = %recovery_err, attempt, max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Failed to recover from unauthorized cloud requirements request; retrying" + "Failed to recover from unauthorized cloud config bundle request; retrying" ); sleep(backoff(attempt as u64)).await; } @@ -470,7 +531,7 @@ impl CloudRequirementsService { tracing::warn!( error = %message, - "Cloud requirements request was unauthorized and no auth recovery is available" + "Cloud config bundle request was unauthorized and no auth recovery is available" ); emit_fetch_final_metric( trigger, @@ -478,48 +539,48 @@ impl CloudRequirementsService { "auth_recovery_unavailable", attempt, status_code, + /*bundle*/ None, ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Auth, + return Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, status_code, CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, )); } }; - let requirements = match contents.as_deref() { - Some(contents) => { - match parse_cloud_requirements(contents, &self.requirements_base_dir) { - Ok(requirements) => requirements, - Err(err) => { - tracing::error!(error = %err, "Failed to parse cloud requirements"); - emit_fetch_final_metric( - trigger, - "error", - "parse_error", - attempt, - last_status_code, - ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Parse, - /*status_code*/ None, - format_cloud_requirements_parse_failed_message(contents, &err), - )); - } - } - } - None => None, - }; + if let Err(err) = validate_bundle(&bundle, &self.codex_home) { + emit_fetch_final_metric( + trigger, + "error", + "invalid_bundle", + attempt, + /*status_code*/ None, + /*bundle*/ None, + ); + return Err(err); + } let (chatgpt_user_id, account_id) = auth_identity(&auth); - if let Err(err) = self.save_cache(chatgpt_user_id, account_id, contents).await { - tracing::warn!(error = %err, "Failed to write cloud requirements cache"); + if let Err(err) = self + .save_cache(chatgpt_user_id, account_id, bundle.clone()) + .await + { + tracing::warn!( + error = %err, + "Failed to write cloud config bundle cache" + ); } emit_fetch_final_metric( - trigger, "success", "none", attempt, /*status_code*/ None, + trigger, + "success", + "none", + attempt, + /*status_code*/ None, + Some(&bundle), ); - return Ok(requirements); + return Ok(optional_bundle(bundle)); } emit_fetch_final_metric( @@ -528,13 +589,14 @@ impl CloudRequirementsService { "request_retry_exhausted", CLOUD_REQUIREMENTS_MAX_ATTEMPTS, last_status_code, + /*bundle*/ None, ); tracing::error!( path = %self.cache_path.display(), "{CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE}" ); - Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::RequestFailed, + Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::RequestFailed, last_status_code, CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE, )) @@ -548,9 +610,9 @@ impl CloudRequirementsService { Ok(false) => break, Err(_) => { tracing::error!( - "Timed out refreshing cloud requirements cache from remote; keeping existing cache" + "Timed out refreshing cloud config bundle cache from remote; keeping existing cache" ); - emit_load_metric("refresh", "error"); + emit_load_metric("refresh", "error", /*bundle*/ None); } } } @@ -565,14 +627,14 @@ impl CloudRequirementsService { } match self.fetch_with_retries(auth, "refresh").await { - Ok(_) => emit_load_metric("refresh", "success"), + Ok(bundle) => emit_load_metric("refresh", "success", bundle.as_ref()), Err(err) => { tracing::error!( path = %self.cache_path.display(), error = %err, - "Failed to refresh cloud requirements cache from remote" + "Failed to refresh cloud config bundle cache from remote" ); - emit_load_metric("refresh", "error"); + emit_load_metric("refresh", "error", /*bundle*/ None); } } true @@ -614,6 +676,11 @@ impl CloudRequirementsService { if !verify_cache_signature(&payload_bytes, &cache_file.signature) { return Err(CacheLoadStatus::CacheSignatureInvalid); } + if cache_file.signed_payload.version != CLOUD_CONFIG_BUNDLE_CACHE_VERSION { + return Err(CacheLoadStatus::CacheVersionUnsupported( + cache_file.signed_payload.version, + )); + } let (Some(cached_chatgpt_user_id), Some(cached_account_id)) = ( cache_file.signed_payload.chatgpt_user_id.as_deref(), @@ -656,7 +723,7 @@ impl CloudRequirementsService { &self, chatgpt_user_id: Option, account_id: Option, - contents: Option, + bundle: CloudConfigBundle, ) -> Result<(), CloudRequirementsError> { let now = Utc::now(); let expires_at = now @@ -666,11 +733,12 @@ impl CloudRequirementsService { ) .ok_or(CloudRequirementsError::CacheWrite)?; let signed_payload = CloudRequirementsCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, cached_at: now, expires_at, chatgpt_user_id, account_id, - contents, + bundle, }; let payload_bytes = cache_payload_bytes(&signed_payload).ok_or(CloudRequirementsError::CacheWrite)?; @@ -694,11 +762,11 @@ impl CloudRequirementsService { } } -pub fn cloud_requirements_loader( +pub fn cloud_config_bundle_loader( auth_manager: Arc, chatgpt_base_url: String, codex_home: PathBuf, -) -> CloudRequirementsLoader { +) -> CloudConfigBundleLoader { let service = CloudRequirementsService::new( auth_manager, Arc::new(BackendRequirementsFetcher::new(chatgpt_base_url)), @@ -710,30 +778,30 @@ pub fn cloud_requirements_loader( let refresh_task = tokio::spawn(async move { refresh_service.refresh_cache_in_background().await }); let mut refresher_guard = refresher_task_slot().lock().unwrap_or_else(|err| { - tracing::warn!("cloud requirements refresher task slot was poisoned"); + tracing::warn!("cloud config bundle refresher task slot was poisoned"); err.into_inner() }); if let Some(existing_task) = refresher_guard.replace(refresh_task) { existing_task.abort(); } - CloudRequirementsLoader::new(async move { + CloudConfigBundleLoader::new(async move { task.await.map_err(|err| { - tracing::error!(error = %err, "Cloud requirements task failed"); - CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Internal, + tracing::error!(error = %err, "Cloud config bundle task failed"); + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Internal, /*status_code*/ None, - format!("cloud requirements load failed: {err}"), + format!("cloud config bundle load failed: {err}"), ) })? }) } -pub async fn cloud_requirements_loader_for_storage( +pub async fn cloud_config_bundle_loader_for_storage( codex_home: PathBuf, enable_codex_api_key_env: bool, credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: String, -) -> CloudRequirementsLoader { +) -> CloudConfigBundleLoader { let auth_manager = AuthManager::shared( codex_home.clone(), enable_codex_api_key_env, @@ -741,31 +809,35 @@ pub async fn cloud_requirements_loader_for_storage( Some(chatgpt_base_url.clone()), ) .await; - cloud_requirements_loader(auth_manager, chatgpt_base_url, codex_home) + cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home) } -fn parse_cloud_requirements( - contents: &str, - requirements_base_dir: &Path, -) -> Result, toml::de::Error> { - if contents.trim().is_empty() { - return Ok(None); - } +fn validate_bundle( + bundle: &CloudConfigBundle, + base_dir: &AbsolutePathBuf, +) -> Result<(), CloudConfigBundleLoadError> { + let bundle_layers = + CloudConfigBundleLayers::from_bundle(bundle.clone(), base_dir).map_err(|err| { + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::InvalidBundle, + /*status_code*/ None, + format!("invalid cloud config bundle: {err}"), + ) + })?; + let CloudConfigBundleLayers { + enterprise_managed_config: _, + enterprise_managed_requirements, + } = bundle_layers; - let _guard = AbsolutePathBufGuard::new(requirements_base_dir); - let requirements: ConfigRequirementsToml = toml::from_str(contents)?; - if requirements.is_empty() { - Ok(None) - } else { - Ok(Some(requirements)) - } -} + compose_requirements(enterprise_managed_requirements).map_err(|err| { + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::InvalidBundle, + /*status_code*/ None, + format!("invalid cloud config bundle: {err}"), + ) + })?; -fn format_cloud_requirements_parse_failed_message( - _contents: &str, - err: &toml::de::Error, -) -> String { - format!("{CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE}\n\nDetails:\n{err}") + Ok(()) } fn emit_fetch_attempt_metric( @@ -793,6 +865,7 @@ fn emit_fetch_final_metric( reason: &str, attempt_count: usize, status_code: Option, + bundle: Option<&CloudConfigBundle>, ) { let attempt_count_tag = attempt_count.to_string(); let status_code_tag = status_code_tag(status_code); @@ -804,20 +877,43 @@ fn emit_fetch_final_metric( ("reason", reason.to_string()), ("attempt_count", attempt_count_tag), ("status_code", status_code_tag), + ("bundle_shape", bundle_shape_tag(bundle)), ], ); } -fn emit_load_metric(trigger: &str, outcome: &str) { +fn emit_load_metric(trigger: &str, outcome: &str, bundle: Option<&CloudConfigBundle>) { emit_metric( CLOUD_REQUIREMENTS_LOAD_METRIC, vec![ ("trigger", trigger.to_string()), ("outcome", outcome.to_string()), + ("bundle_shape", bundle_shape_tag(bundle)), ], ); } +fn bundle_shape_tag(bundle: Option<&CloudConfigBundle>) -> String { + let Some(bundle) = bundle else { + return "none".to_string(); + }; + + let mut sources = Vec::new(); + if !bundle.config_toml.enterprise_managed.is_empty() { + sources.push("enterprise_config"); + } + if !bundle.requirements_toml.enterprise_managed.is_empty() { + sources.push("enterprise_requirements"); + } + + if sources.is_empty() { + "empty".to_string() + } else { + sources.sort_unstable(); + sources.join(",") + } +} + fn status_code_tag(status_code: Option) -> String { status_code .map(|status_code| status_code.to_string()) @@ -839,43 +935,22 @@ mod tests { use super::*; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use codex_config::AppToolApproval; + use codex_backend_client::ConfigBundleResponse; + use codex_backend_client::DeliveredTomlFragment; + use codex_config::CloudConfigFragment; + use codex_config::CloudConfigTomlBundle; + use codex_config::CloudRequirementsFragment; + use codex_config::CloudRequirementsTomlBundle; use codex_config::types::AuthCredentialsStoreMode; - use codex_login::auth::AgentIdentityAuth; - use codex_login::auth::AgentIdentityAuthRecord; - use codex_protocol::protocol::AskForApproval; use pretty_assertions::assert_eq; use serde_json::json; - use std::collections::BTreeMap; use std::collections::VecDeque; - use std::ffi::OsString; use std::future::pending; - use std::io::Read; - use std::io::Write; - use std::net::TcpListener; use std::path::Path; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; - use std::thread; - use tempfile::TempDir; use tempfile::tempdir; - struct EnvVarGuard { - key: &'static str, - original: Option, - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - unsafe { - match &self.original { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } - } - } - fn write_auth_json(codex_home: &Path, value: serde_json::Value) -> std::io::Result<()> { std::fs::write(codex_home.join("auth.json"), serde_json::to_string(&value)?)?; Ok(()) @@ -928,6 +1003,11 @@ mod tests { ) } + async fn auth_manager_with_plan(plan_type: &str) -> Arc { + auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")) + .await + } + fn chatgpt_auth_json( plan_type: &str, chatgpt_user_id: Option<&str>, @@ -1004,55 +1084,44 @@ mod tests { auth_json } - struct ManagedAuthContext { - _home: TempDir, - manager: Arc, - } - - async fn managed_auth_context( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - access_token: &str, - refresh_token: &str, - ) -> ManagedAuthContext { - let home = tempdir().expect("tempdir"); - write_auth_json( - home.path(), - chatgpt_auth_json( - plan_type, - chatgpt_user_id, - account_id, - access_token, - refresh_token, - ), - ) - .expect("write auth"); - ManagedAuthContext { - manager: Arc::new( - AuthManager::new( - home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ), - _home: home, + fn test_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, } } - async fn auth_manager_with_plan(plan_type: &str) -> Arc { - auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")) - .await + fn test_config_fragment() -> CloudConfigFragment { + CloudConfigFragment { + id: "cfg_1".to_string(), + name: "Base config".to_string(), + contents: "model = \"gpt-5\"".to_string(), + } } - fn parse_for_fetch(contents: Option<&str>) -> Option { - contents.and_then(|contents| { - parse_cloud_requirements(contents, &std::env::temp_dir()) - .ok() - .flatten() - }) + fn test_requirements_fragment() -> CloudRequirementsFragment { + CloudRequirementsFragment { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + } + } + + fn invalid_config_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "cfg_invalid".to_string(), + name: "Invalid config".to_string(), + contents: "model = [".to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle::default(), + } } fn request_error() -> FetchAttemptError { @@ -1060,39 +1129,50 @@ mod tests { } struct StaticFetcher { - contents: Option, + bundle: CloudConfigBundle, + request_count: AtomicUsize, } - #[async_trait::async_trait] + impl StaticFetcher { + fn new(bundle: CloudConfigBundle) -> Self { + Self { + bundle, + request_count: AtomicUsize::new(0), + } + } + } + + #[async_trait] impl RequirementsFetcher for StaticFetcher { async fn fetch_requirements( &self, _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { - Ok(self.contents.clone()) + ) -> Result { + self.request_count.fetch_add(1, Ordering::SeqCst); + Ok(self.bundle.clone()) } } struct PendingFetcher; - #[async_trait::async_trait] + #[async_trait] impl RequirementsFetcher for PendingFetcher { async fn fetch_requirements( &self, _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { + ) -> Result { pending::<()>().await; - Ok(None) + Ok(CloudConfigBundle::default()) } } struct SequenceFetcher { - responses: tokio::sync::Mutex, FetchAttemptError>>>, + responses: tokio::sync::Mutex>>, request_count: AtomicUsize, } impl SequenceFetcher { - fn new(responses: Vec, FetchAttemptError>>) -> Self { + fn new(responses: Vec>) -> Self { Self { responses: tokio::sync::Mutex::new(VecDeque::from(responses)), request_count: AtomicUsize::new(0), @@ -1100,40 +1180,42 @@ mod tests { } } - #[async_trait::async_trait] + #[async_trait] impl RequirementsFetcher for SequenceFetcher { async fn fetch_requirements( &self, _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { + ) -> Result { self.request_count.fetch_add(1, Ordering::SeqCst); let mut responses = self.responses.lock().await; - responses.pop_front().unwrap_or(Ok(None)) + responses + .pop_front() + .unwrap_or_else(|| Ok(CloudConfigBundle::default())) } } struct TokenFetcher { expected_token: String, - contents: String, + bundle: CloudConfigBundle, request_count: AtomicUsize, } - #[async_trait::async_trait] + #[async_trait] impl RequirementsFetcher for TokenFetcher { async fn fetch_requirements( &self, auth: &CodexAuth, - ) -> Result, FetchAttemptError> { + ) -> Result { self.request_count.fetch_add(1, Ordering::SeqCst); if matches!( auth.get_token().as_deref(), Ok(token) if token == self.expected_token.as_str() ) { - Ok(Some(self.contents.clone())) + Ok(self.bundle.clone()) } else { Err(FetchAttemptError::Unauthorized { status_code: Some(401), - message: "GET /config/requirements failed: 401".to_string(), + message: "GET /config/bundle failed: 401".to_string(), }) } } @@ -1144,12 +1226,12 @@ mod tests { request_count: AtomicUsize, } - #[async_trait::async_trait] + #[async_trait] impl RequirementsFetcher for UnauthorizedFetcher { async fn fetch_requirements( &self, _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { + ) -> Result { self.request_count.fetch_add(1, Ordering::SeqCst); Err(FetchAttemptError::Unauthorized { status_code: Some(401), @@ -1158,389 +1240,293 @@ mod tests { } } + #[test] + fn bundle_shape_tag_describes_sorted_enterprise_sources() { + assert_eq!(bundle_shape_tag(/*bundle*/ None), "none"); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle::default())), + "empty" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle::default(), + })), + "enterprise_config" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, + })), + "enterprise_requirements" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, + })), + "enterprise_config,enterprise_requirements" + ); + } + #[tokio::test] async fn fetch_cloud_requirements_skips_non_chatgpt_auth() { - let auth_manager = auth_manager_with_api_key().await; + let fetcher = Arc::new(StaticFetcher::new(test_bundle())); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager, - Arc::new(StaticFetcher { contents: None }), + auth_manager_with_api_key().await, + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - let result = service.fetch().await; - assert_eq!(result, Ok(None)); + + assert_eq!(service.fetch().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); } #[tokio::test] async fn fetch_cloud_requirements_skips_non_business_or_enterprise_plan() { + let fetcher = Arc::new(StaticFetcher::new(test_bundle())); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("pro").await, - Arc::new(StaticFetcher { contents: None }), + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - let result = service.fetch().await; - assert_eq!(result, Ok(None)); + + assert_eq!(service.fetch().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); } #[tokio::test] async fn fetch_cloud_requirements_skips_team_like_usage_based_plan() { + let fetcher = Arc::new(StaticFetcher::new(test_bundle())); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("self_serve_business_usage_based").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); + assert_eq!(service.fetch().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); } #[tokio::test] async fn fetch_cloud_requirements_allows_business_plan() { + let bundle = test_bundle(); let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticFetcher::new(bundle.clone())); let service = CloudRequirementsService::new( auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) + + assert_eq!(service.fetch().await, Ok(Some(bundle.clone()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + codex_home + .path() + .join(CLOUD_REQUIREMENTS_CACHE_FILENAME) + .exists() ); } #[tokio::test] - async fn cloud_requirements_eligible_auth_allows_agent_identity_business_plan() { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind task registration server"); - let addr = listener - .local_addr() - .expect("task registration server addr"); - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept task registration request"); - let mut request = [0; 4096]; - let _ = stream - .read(&mut request) - .expect("read task registration request"); - let body = r#"{"task_id":"task-123"}"#; - write!( - stream, - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", - body.len(), - body - ) - .expect("write task registration response"); - }); - let record = AgentIdentityAuthRecord { - agent_runtime_id: "agent-runtime-123".to_string(), - agent_private_key: "MC4CAQAwBQYDK2VwBCIEIDQg14jybCLydjHQwXeBzsDM7oB6BSAenodx6oCovQ/D" - .to_string(), - account_id: "account-12345".to_string(), - chatgpt_user_id: "user-12345".to_string(), - email: "user@example.com".to_string(), - plan_type: PlanType::Business, - chatgpt_account_is_fedramp: false, - }; - let authapi_base_url = format!("http://{addr}/backend-api"); - let original_authapi_base_url = std::env::var_os("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL"); - unsafe { - std::env::set_var("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &authapi_base_url); - } - let _authapi_guard = EnvVarGuard { - key: "CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", - original: original_authapi_base_url, - }; - let auth = AgentIdentityAuth::load(record) - .await - .map(CodexAuth::AgentIdentity) - .expect("agent identity auth"); - server.join().expect("task registration server joined"); + async fn fetch_requirements_rejects_invalid_remote_bundle_before_cache_write() { + let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticFetcher::new(invalid_config_bundle())); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); - assert!(cloud_requirements_eligible_auth(&auth)); + let err = service + .fetch() + .await + .expect_err("invalid remote bundle should fail closed"); + + assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::InvalidBundle); + assert!(err.to_string().contains("invalid cloud config bundle")); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + !codex_home + .path() + .join(CLOUD_REQUIREMENTS_CACHE_FILENAME) + .exists() + ); + } + + #[tokio::test] + async fn fetch_requirements_ignores_invalid_cache_and_refetches() { + let codex_home = tempdir().expect("tempdir"); + let replacement_bundle = test_bundle(); + let fetcher = Arc::new(StaticFetcher::new(replacement_bundle.clone())); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + service + .save_cache( + Some("user-12345".to_string()), + Some("account-12345".to_string()), + invalid_config_bundle(), + ) + .await + .expect("write invalid cache"); + + assert_eq!(service.fetch().await, Ok(Some(replacement_bundle.clone()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await + .expect("load refreshed cache") + .bundle, + replacement_bundle + ); } #[tokio::test] async fn fetch_cloud_requirements_allows_business_like_usage_based_plan() { + let fetcher = Arc::new(StaticFetcher::new(test_bundle())); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("enterprise_cbp_usage_based").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); + + assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } #[tokio::test] async fn fetch_cloud_requirements_allows_hc_plan_as_enterprise() { + let fetcher = Arc::new(StaticFetcher::new(test_bundle())); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("hc").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); + + assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } #[tokio::test] - async fn fetch_cloud_requirements_handles_missing_contents() { - let result = parse_for_fetch(/*contents*/ None); - assert!(result.is_none()); - } - - #[tokio::test] - async fn fetch_cloud_requirements_handles_empty_contents() { - let result = parse_for_fetch(Some(" ")); - assert!(result.is_none()); - } - - #[tokio::test] - async fn fetch_cloud_requirements_handles_invalid_toml() { - let result = parse_for_fetch(Some("not = [")); - assert!(result.is_none()); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_empty_requirements() { - let result = parse_for_fetch(Some("# comment")); - assert!(result.is_none()); - } - - #[tokio::test] - async fn fetch_cloud_requirements_parses_valid_toml() { - let result = parse_for_fetch(Some("allowed_approval_policies = [\"never\"]")); - - assert_eq!( - result, - Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - }) - ); - } - - #[tokio::test] - async fn fetch_cloud_requirements_resolves_relative_deny_read_globs_from_codex_home() { + async fn fetch_requirements_empty_response_is_success_and_cached() { let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticFetcher::new(CloudConfigBundle::default())); let service = CloudRequirementsService::new( auth_manager_with_plan("enterprise").await, - Arc::new(StaticFetcher { - contents: Some( - r#" -[permissions.filesystem] -deny_read = ["./sensitive/**/*.txt"] -"# - .to_string(), - ), - }), + fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - let deny_read = format!("{}/sensitive/**/*.txt", codex_home.path().display()); - let expected = toml::from_str::(&format!( - r#" -[permissions.filesystem] -deny_read = [{deny_read:?}] -"# - )) - .expect("parse expected cloud requirements"); - assert_eq!(service.fetch().await, Ok(Some(expected))); - } - - #[tokio::test] - async fn fetch_cloud_requirements_parses_apps_requirements_toml() { - let result = parse_for_fetch(Some( - r#" -[apps.connector_5f3c8c41a1e54ad7a76272c89e2554fa] -enabled = false -"#, - )); - - assert_eq!( - result, - Some(ConfigRequirementsToml { - apps: Some(codex_config::AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_5f3c8c41a1e54ad7a76272c89e2554fa".to_string(), - codex_config::AppRequirementToml { - enabled: Some(false), - tools: None, - }, - )]), - }), - ..Default::default() - }) + assert_eq!(service.fetch().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + codex_home + .path() + .join(CLOUD_REQUIREMENTS_CACHE_FILENAME) + .exists() ); } #[tokio::test] - async fn fetch_cloud_requirements_parses_apps_tool_requirements_toml() { - let result = parse_for_fetch(Some( - r#" -[apps.connector_5f3c8c41a1e54ad7a76272c89e2554fa.tools."calendar/list_events"] -approval_mode = "approve" -"#, - )); - - assert_eq!( - result, - Some(ConfigRequirementsToml { - apps: Some(codex_config::AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_5f3c8c41a1e54ad7a76272c89e2554fa".to_string(), - codex_config::AppRequirementToml { - enabled: None, - tools: Some(codex_config::AppToolsRequirementsToml { - tools: BTreeMap::from([( - "calendar/list_events".to_string(), - codex_config::AppToolRequirementToml { - approval_mode: Some(AppToolApproval::Approve), - }, - )]), - }), - }, - )]), - }), - ..Default::default() - }) + async fn fetch_cloud_requirements_uses_cache_when_valid() { + let bundle = test_bundle(); + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + Arc::new(StaticFetcher::new(bundle.clone())), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, ); + let _ = prime_service.fetch().await; + + let fetcher = Arc::new(SequenceFetcher::new(vec![Err(request_error())])); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!(service.fetch().await, Ok(Some(bundle))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); } #[tokio::test] - async fn fetch_cloud_requirements_parses_plugin_mcp_requirements_toml() { - let result = parse_for_fetch(Some( - r#" -[plugins."sample@test".mcp_servers.sample.identity] -command = "sample-mcp" -"#, - )); - - assert_eq!( - result, - Some(ConfigRequirementsToml { - plugins: Some(BTreeMap::from([( - "sample@test".to_string(), - codex_config::PluginRequirementsToml { - mcp_servers: Some(BTreeMap::from([( - "sample".to_string(), - codex_config::McpServerRequirement { - identity: codex_config::McpServerIdentity::Command { - command: "sample-mcp".to_string(), - }, - }, - )])), - }, - )])), - ..Default::default() - }) + async fn fetch_cloud_requirements_ignores_cache_for_different_auth_identity() { + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudRequirementsService::new( + auth_manager_with_plan_and_identity( + "business", + Some("user-12345"), + Some("account-12345"), + ) + .await, + Arc::new(StaticFetcher::new(test_bundle())), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, ); + let _ = prime_service.fetch().await; + + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; + let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(replacement_bundle.clone())])); + let service = CloudRequirementsService::new( + auth_manager_with_plan_and_identity( + "business", + Some("user-99999"), + Some("account-12345"), + ) + .await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!(service.fetch().await, Ok(Some(replacement_bundle))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } #[tokio::test(start_paused = true)] async fn fetch_cloud_requirements_times_out() { - let auth_manager = auth_manager_with_plan("enterprise").await; let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager, + auth_manager_with_plan("enterprise").await, Arc::new(PendingFetcher), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1548,11 +1534,11 @@ command = "sample-mcp" let handle = tokio::spawn(async move { service.fetch_with_timeout().await }); tokio::time::advance(CLOUD_REQUIREMENTS_TIMEOUT + Duration::from_millis(1)).await; - let result = handle.await.expect("cloud requirements task"); - let err = result.expect_err("cloud requirements timeout should fail closed"); + let result = handle.await.expect("cloud config bundle task"); + let err = result.expect_err("cloud config bundle timeout should fail closed"); assert!( err.to_string() - .contains("timed out waiting for cloud requirements") + .contains("timed out waiting for cloud config bundle") ); } @@ -1560,7 +1546,7 @@ command = "sample-mcp" async fn fetch_cloud_requirements_retries_until_success() { let fetcher = Arc::new(SequenceFetcher::new(vec![ Err(request_error()), - Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), + Ok(test_bundle()), ])); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( @@ -1574,31 +1560,7 @@ command = "sample-mcp" tokio::task::yield_now().await; tokio::time::advance(Duration::from_secs(1)).await; - assert_eq!( - handle.await.expect("cloud requirements task"), - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); + assert_eq!(handle.await.expect("bundle task"), Ok(Some(test_bundle()))); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); } @@ -1641,49 +1603,20 @@ command = "sample-mcp" ), ) .expect("write refreshed auth"); - let auth = ManagedAuthContext { - _home: auth_home, - manager: auth_manager, - }; - let fetcher = Arc::new(TokenFetcher { expected_token: "fresh-access-token".to_string(), - contents: "allowed_approval_policies = [\"never\"]".to_string(), + bundle: test_bundle(), request_count: AtomicUsize::new(0), }); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - Arc::clone(&auth.manager), + auth_manager, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); + assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); } @@ -1724,77 +1657,57 @@ command = "sample-mcp" ), ) .expect("write refreshed auth"); - let auth = ManagedAuthContext { - _home: auth_home, - manager: auth_manager, - }; - let fetcher = Arc::new(TokenFetcher { expected_token: "fresh-access-token".to_string(), - contents: "allowed_approval_policies = [\"never\"]".to_string(), + bundle: test_bundle(), request_count: AtomicUsize::new(0), }); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - Arc::clone(&auth.manager), + auth_manager, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); + assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) - .expect("parse cache"); - assert_eq!( - cache_file.signed_payload.chatgpt_user_id, - Some("user-99999".to_string()) - ); - assert_eq!( - cache_file.signed_payload.account_id, - Some("account-12345".to_string()) + service + .load_cache(Some("user-99999"), Some("account-12345")) + .await + .expect("load cache") + .bundle, + test_bundle() ); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); } #[tokio::test] async fn fetch_cloud_requirements_surfaces_auth_recovery_message() { - let auth = managed_auth_context( - "enterprise", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - ) - .await; + let auth_home = tempdir().expect("tempdir"); write_auth_json( - auth._home.path(), + auth_home.path(), + chatgpt_auth_json( + "enterprise", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + ), + ) + .expect("write auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + write_auth_json( + auth_home.path(), chatgpt_auth_json( "enterprise", Some("user-12345"), @@ -1804,14 +1717,13 @@ command = "sample-mcp" ), ) .expect("write mismatched auth"); - let fetcher = Arc::new(UnauthorizedFetcher { - message: "GET /config/requirements failed: 401".to_string(), + message: "GET /config/bundle failed: 401".to_string(), request_count: AtomicUsize::new(0), }); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - Arc::clone(&auth.manager), + auth_manager, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1820,10 +1732,14 @@ command = "sample-mcp" let err = service .fetch() .await - .expect_err("cloud requirements should surface auth recovery errors"); + .expect_err("cloud config bundle should surface auth recovery errors"); assert_eq!( - err.to_string(), - "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again." + err, + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + Some(401), + "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.", + ) ); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } @@ -1856,7 +1772,7 @@ command = "sample-mcp" let fetcher = Arc::new(UnauthorizedFetcher { message: - "GET https://chatgpt.com/backend-api/wham/config/requirements failed: 401; content-type=text/html; body=nope" + "GET https://chatgpt.com/backend-api/wham/config/bundle failed: 401; content-type=text/html; body=nope" .to_string(), request_count: AtomicUsize::new(0), }); @@ -1871,168 +1787,16 @@ command = "sample-mcp" let err = service .fetch() .await - .expect_err("cloud requirements should fail closed"); + .expect_err("cloud config bundle should fail closed"); assert_eq!( - err.to_string(), - CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE - ); - assert_eq!(err.code(), CloudRequirementsLoadErrorCode::Auth); - assert_eq!(err.status_code(), Some(401)); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_parse_error_does_not_retry() { - let fetcher = Arc::new(SequenceFetcher::new(vec![ - Ok(Some("not = [".to_string())), - Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), - ])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let err = service - .fetch() - .await - .expect_err("parse error should fail closed"); - let err_text = err.to_string(); - assert!(err_text.contains(CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE)); - assert!(err_text.contains("Details:")); - assert!(err_text.contains("not = [")); - assert_eq!(err.code(), CloudRequirementsLoadErrorCode::Parse); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_invalid_enum_value_surfaces_field_name() { - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"definitely-not-valid\"]".to_string(), - ))])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher, - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let err = service - .fetch() - .await - .expect_err("invalid enum value should fail closed"); - let err_text = err.to_string(); - assert!(err_text.contains(CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE)); - assert!(err_text.contains("allowed_approval_policies")); - assert!(err_text.contains("definitely-not-valid")); - assert!(err_text.contains("unknown variant")); - assert_eq!(err.code(), CloudRequirementsLoadErrorCode::Parse); - } - - #[tokio::test] - async fn fetch_cloud_requirements_uses_cache_when_valid() { - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let fetcher = Arc::new(SequenceFetcher::new(vec![Err(request_error())])); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn fetch_cloud_requirements_writes_cache_when_identity_is_incomplete() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - /*chatgpt_user_id*/ None, - Some("account-12345"), + err, + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + Some(401), + CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, ) - .await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) - .expect("parse cache"); - assert_eq!(cache_file.signed_payload.chatgpt_user_id, None); - assert_eq!( - cache_file.signed_payload.account_id, - Some("account-12345".to_string()) ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } #[tokio::test] @@ -2040,17 +1804,23 @@ command = "sample-mcp" let codex_home = tempdir().expect("tempdir"); let prime_service = CloudRequirementsService::new( auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), + Arc::new(StaticFetcher::new(test_bundle())), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); let _ = prime_service.fetch().await; - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"on-request\"]".to_string(), - ))])); + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; + let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(replacement_bundle.clone())])); let service = CloudRequirementsService::new( auth_manager_with_plan_and_identity( "business", @@ -2063,306 +1833,7 @@ command = "sample-mcp" CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_cache_for_different_auth_identity() { - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - Some("user-12345"), - Some("account-12345"), - ) - .await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"on-request\"]".to_string(), - ))])); - let service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - Some("user-99999"), - Some("account-12345"), - ) - .await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_tampered_cache() { - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let mut cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(&path).expect("read cache")) - .expect("parse cache"); - cache_file.signed_payload.contents = - Some("allowed_approval_policies = [\"on-request\"]".to_string()); - std::fs::write( - &path, - serde_json::to_vec_pretty(&cache_file).expect("serialize cache"), - ) - .expect("write cache"); - - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"never\"]".to_string(), - ))])); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_expired_cache() { - let codex_home = tempdir().expect("tempdir"); - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file = CloudRequirementsCacheFile { - signed_payload: CloudRequirementsCacheSignedPayload { - cached_at: Utc::now(), - expires_at: Utc::now() - ChronoDuration::seconds(1), - chatgpt_user_id: Some("user-12345".to_string()), - account_id: Some("account-12345".to_string()), - contents: Some("allowed_approval_policies = [\"on-request\"]".to_string()), - }, - signature: String::new(), - }; - let payload_bytes = cache_payload_bytes(&cache_file.signed_payload).expect("payload"); - let signature = sign_cache_payload(&payload_bytes).expect("sign payload"); - let cache_file = CloudRequirementsCacheFile { - signature, - ..cache_file - }; - std::fs::write( - &path, - serde_json::to_vec_pretty(&cache_file).expect("serialize cache"), - ) - .expect("write cache"); - - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"never\"]".to_string(), - ))])); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_writes_signed_cache() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let _ = service.fetch().await; - - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) - .expect("parse cache"); - assert!( - cache_file.signed_payload.expires_at - <= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30) - ); - assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at); - assert!(cache_file.signed_payload.cached_at <= Utc::now()); - assert_eq!( - cache_file.signed_payload.chatgpt_user_id, - Some("user-12345".to_string()) - ); - assert_eq!( - cache_file.signed_payload.account_id, - Some("account-12345".to_string()) - ); - assert_eq!( - cache_file - .signed_payload - .contents - .as_deref() - .and_then(|contents| { - parse_cloud_requirements(contents, codex_home.path()) - .ok() - .flatten() - }), - Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - }) - ); - let payload_bytes = cache_payload_bytes(&cache_file.signed_payload).expect("payload bytes"); - assert!(verify_cache_signature( - &payload_bytes, - &cache_file.signature - )); - } - - #[tokio::test] - async fn fetch_cloud_requirements_none_is_success_without_retry() { - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(None), Err(request_error())])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(None)); + assert_eq!(service.fetch().await, Ok(Some(replacement_bundle))); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } @@ -2387,10 +1858,10 @@ command = "sample-mcp" let err = handle .await - .expect("cloud requirements task") - .expect_err("cloud requirements retry exhaustion should fail closed"); + .expect("cloud config bundle task") + .expect_err("cloud config bundle retry exhaustion should fail closed"); assert_eq!(err.to_string(), CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE); - assert_eq!(err.code(), CloudRequirementsLoadErrorCode::RequestFailed); + assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::RequestFailed); assert_eq!( fetcher.request_count.load(Ordering::SeqCst), CLOUD_REQUIREMENTS_MAX_ATTEMPTS @@ -2398,13 +1869,21 @@ command = "sample-mcp" } #[tokio::test] - async fn refresh_from_remote_updates_cached_cloud_requirements() { + async fn refresh_from_remote_updates_cached_bundle() { + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; let codex_home = tempdir().expect("tempdir"); let fetcher = Arc::new(SequenceFetcher::new(vec![ - Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), - Ok(Some( - "allowed_approval_policies = [\"on-request\"]".to_string(), - )), + Ok(test_bundle()), + Ok(replacement_bundle.clone()), ])); let service = CloudRequirementsService::new( auth_manager_with_plan("business").await, @@ -2413,70 +1892,331 @@ command = "sample-mcp" CLOUD_REQUIREMENTS_TIMEOUT, ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - + assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); assert!(service.refresh_cache().await); - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) - .expect("parse cache"); + let signed_payload = service + .load_cache(Some("user-12345"), Some("account-12345")) + .await + .expect("load cache"); + assert_eq!(signed_payload.bundle, replacement_bundle); + } + + #[test] + fn bundle_response_conversion_preserves_fragment_order() { + let response = ConfigBundleResponse { + config_toml: Some(Some(Box::new(codex_backend_client::DeliveredConfigToml { + enterprise_managed: Some(Some(vec![ + DeliveredTomlFragment::new( + "cfg_high".to_string(), + "High config".to_string(), + "model = \"high\"".to_string(), + ), + DeliveredTomlFragment::new( + "cfg_low".to_string(), + "Low config".to_string(), + "model = \"low\"".to_string(), + ), + ])), + }))), + requirements_toml: Some(Some(Box::new( + codex_backend_client::DeliveredRequirementsToml { + enterprise_managed: Some(Some(vec![DeliveredTomlFragment::new( + "req_high".to_string(), + "High requirements".to_string(), + "allowed_approval_policies = [\"never\"]".to_string(), + )])), + }, + ))), + }; + assert_eq!( - cache_file - .signed_payload - .contents - .as_deref() - .and_then(|contents| { - parse_cloud_requirements(contents, codex_home.path()) - .ok() - .flatten() - }), - Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - }) + bundle_from_response(response), + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![ + CloudConfigFragment { + id: "cfg_high".to_string(), + name: "High config".to_string(), + contents: "model = \"high\"".to_string(), + }, + CloudConfigFragment { + id: "cfg_low".to_string(), + name: "Low config".to_string(), + contents: "model = \"low\"".to_string(), + }, + ], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_high".to_string(), + name: "High requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + }], + }, + } + ); + } + + #[test] + fn bundle_response_conversion_treats_missing_sections_as_empty() { + assert_eq!( + bundle_from_response(ConfigBundleResponse::new()), + CloudConfigBundle::default() + ); + } +} + +#[cfg(test)] +mod cache_tests { + use super::*; + use codex_config::CloudConfigFragment; + use codex_config::CloudConfigTomlBundle; + use codex_config::CloudRequirementsFragment; + use codex_config::CloudRequirementsTomlBundle; + use pretty_assertions::assert_eq; + use std::path::Path; + use tempfile::tempdir; + + fn test_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "cfg_1".to_string(), + name: "Base config".to_string(), + contents: "model = \"gpt-5\"".to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + }], + }, + } + } + + struct NeverFetcher; + + #[async_trait] + impl RequirementsFetcher for NeverFetcher { + async fn fetch_requirements( + &self, + _auth: &CodexAuth, + ) -> Result { + panic!("cache tests should not fetch from remote"); + } + } + + async fn create_test_service(codex_home: &Path) -> CloudRequirementsService { + let auth_home = tempdir().expect("tempdir"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + CloudRequirementsService::new( + auth_manager, + Arc::new(NeverFetcher), + codex_home.to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ) + } + + fn signed_cache_file( + signed_payload: CloudRequirementsCacheSignedPayload, + ) -> CloudRequirementsCacheFile { + let payload_bytes = cache_payload_bytes(&signed_payload).expect("payload bytes"); + CloudRequirementsCacheFile { + signature: sign_cache_payload(&payload_bytes).expect("signature"), + signed_payload, + } + } + + fn valid_signed_payload() -> CloudRequirementsCacheSignedPayload { + let cached_at = Utc::now(); + CloudRequirementsCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, + cached_at, + expires_at: cached_at + ChronoDuration::minutes(30), + chatgpt_user_id: Some("user-12345".to_string()), + account_id: Some("account-12345".to_string()), + bundle: test_bundle(), + } + } + + fn write_cache_file(cache_path: &Path, cache_file: &CloudRequirementsCacheFile) { + std::fs::write( + cache_path, + serde_json::to_vec_pretty(cache_file).expect("serialize cache"), + ) + .expect("write cache"); + } + + #[tokio::test] + async fn save_writes_signed_payload_and_loads_for_matching_identity() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + let bundle = test_bundle(); + + service + .save_cache( + Some("user-12345".to_string()), + Some("account-12345".to_string()), + bundle.clone(), + ) + .await + .expect("save cache"); + + let cache_file: CloudRequirementsCacheFile = + serde_json::from_slice(&std::fs::read(&service.cache_path).expect("read cache")) + .expect("parse cache"); + assert!( + cache_file.signed_payload.expires_at + <= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30) + ); + assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at); + assert_eq!( + cache_file, + signed_cache_file(CloudRequirementsCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, + cached_at: cache_file.signed_payload.cached_at, + expires_at: cache_file.signed_payload.expires_at, + chatgpt_user_id: Some("user-12345".to_string()), + account_id: Some("account-12345".to_string()), + bundle, + }) + ); + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Ok(cache_file.signed_payload) + ); + } + + #[tokio::test] + async fn load_rejects_missing_request_identity_before_reading_cache_file() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + + assert_eq!( + service + .load_cache(/*chatgpt_user_id*/ None, Some("account-12345")) + .await, + Err(CacheLoadStatus::AuthIdentityIncomplete) + ); + assert_eq!( + service + .load_cache(Some("user-12345"), /*account_id*/ None) + .await, + Err(CacheLoadStatus::AuthIdentityIncomplete) + ); + } + + #[tokio::test] + async fn load_reports_missing_and_malformed_cache_files() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheFileNotFound) + ); + + std::fs::write(&service.cache_path, "{").expect("write malformed cache"); + assert!(matches!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheParseFailed(_)) + )); + } + + #[tokio::test] + async fn load_rejects_tampered_payload() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + let mut cache_file = signed_cache_file(valid_signed_payload()); + cache_file + .signed_payload + .bundle + .requirements_toml + .enterprise_managed[0] + .contents = "allowed_approval_policies = [\"on-request\"]".to_string(); + write_cache_file(&service.cache_path, &cache_file); + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheSignatureInvalid) + ); + } + + #[tokio::test] + async fn load_rejects_cache_for_incomplete_or_different_identity() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + let cache_file = signed_cache_file(valid_signed_payload()); + write_cache_file(&service.cache_path, &cache_file); + + assert_eq!( + service + .load_cache(Some("user-99999"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheIdentityMismatch) + ); + + let mut signed_payload = valid_signed_payload(); + signed_payload.chatgpt_user_id = None; + write_cache_file(&service.cache_path, &signed_cache_file(signed_payload)); + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheIdentityIncomplete) + ); + } + + #[tokio::test] + async fn load_rejects_expired_cache() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + let mut signed_payload = valid_signed_payload(); + signed_payload.expires_at = Utc::now() - ChronoDuration::seconds(1); + write_cache_file(&service.cache_path, &signed_cache_file(signed_payload)); + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheExpired) + ); + } + + #[tokio::test] + async fn load_rejects_unsupported_cache_version() { + let codex_home = tempdir().expect("tempdir"); + let service = create_test_service(codex_home.path()).await; + let mut signed_payload = valid_signed_payload(); + signed_payload.version = 2; + write_cache_file(&service.cache_path, &signed_cache_file(signed_payload)); + + assert_eq!( + service + .load_cache(Some("user-12345"), Some("account-12345")) + .await, + Err(CacheLoadStatus::CacheVersionUnsupported(2)) ); } } diff --git a/codex-rs/config/src/cloud_config_bundle.rs b/codex-rs/config/src/cloud_config_bundle.rs new file mode 100644 index 0000000000..cfece32a01 --- /dev/null +++ b/codex-rs/config/src/cloud_config_bundle.rs @@ -0,0 +1,214 @@ +//! Cloud config bundle domain model and shared in-memory loader. +//! +//! The backend bundle groups cloud-delivered config and requirements fragments +//! by source bucket. `CloudConfigBundleLayers` converts those raw buckets into +//! layer entries while preserving each bucket's insertion semantics. + +use crate::CloudConfigFragment; +use crate::ConfigLayerEntry; +use crate::RequirementSource; +use crate::RequirementsLayerEntry; +use crate::cloud_config_layers::CloudConfigLayerError; +use crate::cloud_config_layers::cloud_config_layers_from_fragments_strict; +use crate::cloud_config_layers_from_fragments; +use codex_utils_absolute_path::AbsolutePathBuf; +use futures::future::BoxFuture; +use futures::future::FutureExt; +use futures::future::Shared; +use serde::Deserialize; +use serde::Serialize; +use std::fmt; +use std::future::Future; +use thiserror::Error; + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudConfigBundle { + pub config_toml: CloudConfigTomlBundle, + pub requirements_toml: CloudRequirementsTomlBundle, +} + +impl CloudConfigBundle { + pub fn is_empty(&self) -> bool { + let CloudConfigBundle { + config_toml, + requirements_toml, + } = self; + let CloudConfigTomlBundle { + enterprise_managed: config_enterprise_managed, + } = config_toml; + let CloudRequirementsTomlBundle { + enterprise_managed: requirements_enterprise_managed, + } = requirements_toml; + + config_enterprise_managed.is_empty() && requirements_enterprise_managed.is_empty() + } +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudConfigTomlBundle { + pub enterprise_managed: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudRequirementsTomlBundle { + pub enterprise_managed: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudRequirementsFragment { + pub id: String, + pub name: String, + pub contents: String, +} + +/// Cloud config bundle converted into semantic layer buckets. +/// +/// This is not a final config stack. Callers still decide where each bucket is +/// inserted relative to local/system/user layers. +#[derive(Clone, Debug)] +pub struct CloudConfigBundleLayers { + /// Enterprise-managed config layers in `ConfigLayerStack` order. + pub enterprise_managed_config: Vec, + /// Enterprise-managed requirements layers in requirements layer merge order. + pub enterprise_managed_requirements: Vec, +} + +impl CloudConfigBundleLayers { + pub fn from_bundle( + bundle: CloudConfigBundle, + base_dir: &AbsolutePathBuf, + ) -> Result { + Self::from_bundle_impl(bundle, base_dir, /*strict_config*/ false) + } + + pub fn from_bundle_strict_config( + bundle: CloudConfigBundle, + base_dir: &AbsolutePathBuf, + ) -> Result { + Self::from_bundle_impl(bundle, base_dir, /*strict_config*/ true) + } + + fn from_bundle_impl( + bundle: CloudConfigBundle, + base_dir: &AbsolutePathBuf, + strict_config: bool, + ) -> Result { + // Keep this destructuring exhaustive so adding a new bundle bucket forces + // an explicit choice about how it becomes layer data. + let CloudConfigBundle { + config_toml: + CloudConfigTomlBundle { + enterprise_managed: config_enterprise_managed, + }, + requirements_toml: + CloudRequirementsTomlBundle { + enterprise_managed: requirements_enterprise_managed, + }, + } = bundle; + + let enterprise_managed_config = if strict_config { + cloud_config_layers_from_fragments_strict(config_enterprise_managed, base_dir)? + } else { + cloud_config_layers_from_fragments(config_enterprise_managed, base_dir)? + }; + + let mut enterprise_managed_requirements = requirements_enterprise_managed + .into_iter() + .map(|fragment| { + RequirementsLayerEntry::from_toml( + RequirementSource::EnterpriseManaged { + id: fragment.id, + name: fragment.name, + }, + fragment.contents, + ) + .with_base_dir(base_dir.clone()) + }) + .collect::>(); + // Bundle fragments arrive highest-priority first, while requirements + // layers are merged lowest-priority to highest-priority. + enterprise_managed_requirements.reverse(); + + Ok(Self { + enterprise_managed_config, + enterprise_managed_requirements, + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CloudConfigBundleLoadErrorCode { + Auth, + Timeout, + RequestFailed, + InvalidBundle, + Internal, +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +#[error("{message}")] +pub struct CloudConfigBundleLoadError { + code: CloudConfigBundleLoadErrorCode, + message: String, + status_code: Option, +} + +impl CloudConfigBundleLoadError { + pub fn new( + code: CloudConfigBundleLoadErrorCode, + status_code: Option, + message: impl Into, + ) -> Self { + Self { + code, + message: message.into(), + status_code, + } + } + + pub fn code(&self) -> CloudConfigBundleLoadErrorCode { + self.code + } + + pub fn status_code(&self) -> Option { + self.status_code + } +} + +#[derive(Clone)] +pub struct CloudConfigBundleLoader { + fut: Shared, CloudConfigBundleLoadError>>>, +} + +impl CloudConfigBundleLoader { + pub fn new(fut: F) -> Self + where + F: Future, CloudConfigBundleLoadError>> + + Send + + 'static, + { + Self { + fut: fut.boxed().shared(), + } + } + + pub async fn get(&self) -> Result, CloudConfigBundleLoadError> { + self.fut.clone().await + } +} + +impl fmt::Debug for CloudConfigBundleLoader { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CloudConfigBundleLoader").finish() + } +} + +impl Default for CloudConfigBundleLoader { + fn default() -> Self { + Self::new(async { Ok(None) }) + } +} + +#[cfg(test)] +#[path = "cloud_config_bundle_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/cloud_config_bundle_tests.rs b/codex-rs/config/src/cloud_config_bundle_tests.rs new file mode 100644 index 0000000000..714aca6bc6 --- /dev/null +++ b/codex-rs/config/src/cloud_config_bundle_tests.rs @@ -0,0 +1,125 @@ +use super::*; +use crate::ConfigLayerSource; +use crate::ConfigRequirementsToml; +use crate::compose_requirements; +use codex_protocol::protocol::AskForApproval; +use pretty_assertions::assert_eq; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use tempfile::tempdir; + +#[tokio::test] +async fn shared_future_runs_once() { + let counter = Arc::new(AtomicUsize::new(0)); + let counter_clone = Arc::clone(&counter); + let loader = CloudConfigBundleLoader::new(async move { + counter_clone.fetch_add(1, Ordering::SeqCst); + Ok(Some(CloudConfigBundle::default())) + }); + + let (first, second) = tokio::join!(loader.get(), loader.get()); + assert_eq!(first, second); + assert_eq!(counter.load(Ordering::SeqCst), 1); +} + +#[test] +fn bundle_layers_preserve_enterprise_managed_bucket_order() { + let tempdir = tempdir().expect("tempdir"); + let base_dir = AbsolutePathBuf::from_absolute_path(tempdir.path()).expect("absolute path"); + let layers = CloudConfigBundleLayers::from_bundle( + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![ + CloudConfigFragment { + id: "cfg_high".to_string(), + name: "High config".to_string(), + contents: "model = \"high\"".to_string(), + }, + CloudConfigFragment { + id: "cfg_low".to_string(), + name: "Low config".to_string(), + contents: "model = \"low\"".to_string(), + }, + ], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![ + CloudRequirementsFragment { + id: "req_high".to_string(), + name: "High requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }, + CloudRequirementsFragment { + id: "req_low".to_string(), + name: "Low requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + }, + ], + }, + }, + &base_dir, + ) + .expect("bundle should be converted into layers"); + + assert_eq!( + layers + .enterprise_managed_config + .iter() + .map(|layer| layer.name.clone()) + .collect::>(), + vec![ + ConfigLayerSource::EnterpriseManaged { + id: "cfg_low".to_string(), + name: "Low config".to_string(), + }, + ConfigLayerSource::EnterpriseManaged { + id: "cfg_high".to_string(), + name: "High config".to_string(), + }, + ] + ); + assert_eq!( + compose_requirements(layers.enterprise_managed_requirements) + .expect("requirements should compose") + .expect("requirements should be present") + .into_toml(), + ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + ..Default::default() + } + ); +} + +#[test] +fn bundle_layers_can_strict_validate_enterprise_managed_config() { + let tempdir = tempdir().expect("tempdir"); + let base_dir = AbsolutePathBuf::from_absolute_path(tempdir.path()).expect("absolute path"); + let err = CloudConfigBundleLayers::from_bundle_strict_config( + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "cfg".to_string(), + name: "Cloud config".to_string(), + contents: "unknown_key = true".to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: Vec::new(), + }, + }, + &base_dir, + ) + .expect_err("strict config should reject unknown fields"); + + assert_eq!( + err, + CloudConfigLayerError::Invalid { + fragment: crate::CloudConfigFragmentSource { + id: "cfg".to_string(), + name: "Cloud config".to_string(), + }, + message: "unknown configuration field `unknown_key`".to_string(), + } + ); +} diff --git a/codex-rs/config/src/cloud_config_layers.rs b/codex-rs/config/src/cloud_config_layers.rs index bdb11631e4..f6fcd40b4a 100644 --- a/codex-rs/config/src/cloud_config_layers.rs +++ b/codex-rs/config/src/cloud_config_layers.rs @@ -7,8 +7,13 @@ use crate::ConfigLayerEntry; use crate::ConfigLayerSource; use crate::TomlValue; +use crate::config_toml::ConfigToml; use crate::loader::resolve_relative_paths_in_config_toml; +use crate::strict_config::config_error_from_ignored_toml_value_fields_for_source_name; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use serde::Deserialize; +use serde::Serialize; use std::fmt; use std::io; use thiserror::Error; @@ -18,7 +23,7 @@ use thiserror::Error; /// The bundle orders fragments from highest precedence to lowest precedence. /// This module returns config layers in stack order, so callers can append the /// result between system and user config without re-sorting. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct CloudConfigFragment { pub id: String, pub name: String, @@ -63,6 +68,21 @@ pub enum CloudConfigLayerError { pub fn cloud_config_layers_from_fragments( fragments: impl IntoIterator, base_dir: &AbsolutePathBuf, +) -> Result, CloudConfigLayerError> { + cloud_config_layers_from_fragments_impl(fragments, base_dir, /*strict_config*/ false) +} + +pub(crate) fn cloud_config_layers_from_fragments_strict( + fragments: impl IntoIterator, + base_dir: &AbsolutePathBuf, +) -> Result, CloudConfigLayerError> { + cloud_config_layers_from_fragments_impl(fragments, base_dir, /*strict_config*/ true) +} + +fn cloud_config_layers_from_fragments_impl( + fragments: impl IntoIterator, + base_dir: &AbsolutePathBuf, + strict_config: bool, ) -> Result, CloudConfigLayerError> { let mut layers = Vec::new(); for fragment in fragments { @@ -73,6 +93,9 @@ pub fn cloud_config_layers_from_fragments( fragment: source_ref.clone(), message: err.to_string(), })?; + if strict_config { + validate_fragment_strictly(&source_ref, &raw_toml, &value, base_dir)?; + } let resolved = resolve_relative_paths_in_config_toml(value, base_dir.as_path()).map_err(|err| { CloudConfigLayerError::Invalid { @@ -97,6 +120,26 @@ pub fn cloud_config_layers_from_fragments( Ok(layers) } +fn validate_fragment_strictly( + source_ref: &CloudConfigFragmentSource, + raw_toml: &str, + value: &TomlValue, + base_dir: &AbsolutePathBuf, +) -> Result<(), CloudConfigLayerError> { + let _guard = AbsolutePathBufGuard::new(base_dir.as_path()); + if let Some(config_error) = config_error_from_ignored_toml_value_fields_for_source_name::< + ConfigToml, + >(&source_ref.to_string(), raw_toml, value.clone()) + { + return Err(CloudConfigLayerError::Invalid { + fragment: source_ref.clone(), + message: config_error.message, + }); + } + + Ok(()) +} + impl From for io::Error { fn from(error: CloudConfigLayerError) -> Self { io::Error::new(io::ErrorKind::InvalidData, error) diff --git a/codex-rs/config/src/cloud_config_layers_tests.rs b/codex-rs/config/src/cloud_config_layers_tests.rs index c7f685fa67..c2bc7383bc 100644 --- a/codex-rs/config/src/cloud_config_layers_tests.rs +++ b/codex-rs/config/src/cloud_config_layers_tests.rs @@ -58,6 +58,27 @@ fn layers_are_returned_in_stack_order() { ); } +#[test] +fn strict_layers_reject_unknown_config_fields() { + let base_dir = base_dir(); + let err = cloud_config_layers_from_fragments_strict( + vec![fragment("strict", "Strict layer", "unknown_key = true")], + &base_dir, + ) + .expect_err("strict config should reject unknown fields"); + + assert_eq!( + err, + CloudConfigLayerError::Invalid { + fragment: CloudConfigFragmentSource { + id: "strict".to_string(), + name: "Strict layer".to_string(), + }, + message: "unknown configuration field `unknown_key`".to_string(), + } + ); +} + #[test] fn enterprise_layers_precede_user_and_override_system() { let base_dir = base_dir(); diff --git a/codex-rs/config/src/cloud_requirements.rs b/codex-rs/config/src/cloud_requirements.rs deleted file mode 100644 index 85b904824d..0000000000 --- a/codex-rs/config/src/cloud_requirements.rs +++ /dev/null @@ -1,105 +0,0 @@ -use crate::config_requirements::ConfigRequirementsToml; -use futures::future::BoxFuture; -use futures::future::FutureExt; -use futures::future::Shared; -use std::fmt; -use std::future::Future; -use thiserror::Error; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum CloudRequirementsLoadErrorCode { - Auth, - Timeout, - Parse, - RequestFailed, - Internal, -} - -#[derive(Clone, Debug, Eq, Error, PartialEq)] -#[error("{message}")] -pub struct CloudRequirementsLoadError { - code: CloudRequirementsLoadErrorCode, - message: String, - status_code: Option, -} - -impl CloudRequirementsLoadError { - pub fn new( - code: CloudRequirementsLoadErrorCode, - status_code: Option, - message: impl Into, - ) -> Self { - Self { - code, - message: message.into(), - status_code, - } - } - - pub fn code(&self) -> CloudRequirementsLoadErrorCode { - self.code - } - - pub fn status_code(&self) -> Option { - self.status_code - } -} - -#[derive(Clone)] -pub struct CloudRequirementsLoader { - fut: Shared< - BoxFuture<'static, Result, CloudRequirementsLoadError>>, - >, -} - -impl CloudRequirementsLoader { - pub fn new(fut: F) -> Self - where - F: Future, CloudRequirementsLoadError>> - + Send - + 'static, - { - Self { - fut: fut.boxed().shared(), - } - } - - pub async fn get(&self) -> Result, CloudRequirementsLoadError> { - self.fut.clone().await - } -} - -impl fmt::Debug for CloudRequirementsLoader { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CloudRequirementsLoader").finish() - } -} - -impl Default for CloudRequirementsLoader { - fn default() -> Self { - Self::new(async { Ok(None) }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use std::sync::Arc; - use std::sync::atomic::AtomicUsize; - use std::sync::atomic::Ordering; - - #[tokio::test] - async fn shared_future_runs_once() { - let counter = Arc::new(AtomicUsize::new(0)); - let counter_clone = Arc::clone(&counter); - let loader = CloudRequirementsLoader::new(async move { - counter_clone.fetch_add(1, Ordering::SeqCst); - Ok(Some(ConfigRequirementsToml::default())) - }); - - let (first, second) = tokio::join!(loader.get(), loader.get()); - assert_eq!(first, second); - assert_eq!(counter.load(Ordering::SeqCst), 1); - } -} diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 86d64e4153..dcaefb8dbe 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -29,7 +29,6 @@ pub enum RequirementSource { domain: String, key: String, }, - CloudRequirements, /// Multiple requirements layers contributed to the final value. Sources are /// stored highest-priority first, matching the order surfaced in errors. Composite { @@ -87,9 +86,6 @@ impl fmt::Display for RequirementSource { RequirementSource::MdmManagedPreferences { domain, key } => { write!(f, "MDM {domain}:{key}") } - RequirementSource::CloudRequirements => { - write!(f, "cloud requirements") - } RequirementSource::Composite { sources } => { write!(f, "requirements layers: ")?; for (index, source) in sources.iter().enumerate() { @@ -1510,22 +1506,6 @@ mod tests { ); } - #[test] - fn composite_requirement_source_display_lists_sources_in_priority_order() { - let source = RequirementSource::composite([ - RequirementSource::MdmManagedPreferences { - domain: "com.openai.codex".to_string(), - key: "requirements_toml_base64".to_string(), - }, - RequirementSource::LegacyManagedConfigTomlFromMdm, - ]); - - assert_eq!( - source.to_string(), - "requirements layers: MDM com.openai.codex:requirements_toml_base64, MDM managed_config.toml (legacy)" - ); - } - fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources { let ConfigRequirementsToml { allowed_approval_policies, @@ -1906,7 +1886,7 @@ mod tests { fn merge_unset_fields_ignores_blank_guardian_override() { let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields( - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, ConfigRequirementsToml { guardian_policy_config: Some(" \n\t".to_string()), ..Default::default() @@ -2252,8 +2232,11 @@ allowed_approvals_reviewers = ["user"] #[test] fn merge_unset_fields_merges_apps_across_sources_with_enabled_evaluation() { - let higher_source = RequirementSource::CloudRequirements; - let lower_source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let higher_source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let lower_source = RequirementSource::MdmManagedPreferences { + domain: "com.openai.codex".to_string(), + key: "requirements_toml_base64".to_string(), + }; let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields( @@ -2294,7 +2277,7 @@ allowed_approvals_reviewers = ["user"] let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields( - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, ConfigRequirementsToml { apps: Some(apps_requirements(&[])), ..Default::default() @@ -2415,7 +2398,7 @@ allowed_approvals_reviewers = ["user"] "#, )?; - let source_location = RequirementSource::CloudRequirements; + let source_location = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields(source_location.clone(), source); let requirements = ConfigRequirements::try_from(target)?; @@ -2725,7 +2708,7 @@ allowed_approvals_reviewers = ["user"] #[test] fn remote_sandbox_config_first_match_overrides_top_level() -> Result<()> { - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut requirements_toml: ConfigRequirementsToml = from_str( r#" allowed_sandbox_modes = ["read-only"] @@ -2816,7 +2799,7 @@ allowed_approvals_reviewers = ["user"] #[test] fn remote_sandbox_config_does_not_override_higher_precedence_sandbox_modes() -> Result<()> { - let high_source = RequirementSource::CloudRequirements; + let high_source = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut high_precedence: ConfigRequirementsToml = from_str( r#" allowed_sandbox_modes = ["read-only"] @@ -3003,7 +2986,7 @@ statusMessage = "checking" fn merge_unset_fields_does_not_overwrite_existing_hooks() -> Result<()> { let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields( - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, from_str::( r#" [hooks] @@ -3047,7 +3030,7 @@ command = "python3 /system/hooks/pre.py" ); assert_eq!( target.hooks.as_ref().map(|hooks| hooks.source.clone()), - Some(RequirementSource::CloudRequirements) + Some(RequirementSource::LegacyManagedConfigTomlFromMdm) ); Ok(()) } @@ -3111,7 +3094,7 @@ command = "python3 /enterprise/hooks/pre.py" "/tmp/blocked.sock" = "deny" "#; - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut requirements_with_sources = ConfigRequirementsWithSources::default(); requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?); @@ -3184,7 +3167,7 @@ command = "python3 /enterprise/hooks/pre.py" allow_local_binding = false "#; - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut requirements_with_sources = ConfigRequirementsWithSources::default(); requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?); diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index cb3984729b..0c79ffacff 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -1,5 +1,5 @@ +mod cloud_config_bundle; mod cloud_config_layers; -mod cloud_requirements; mod config_layer_source; mod config_requirements; pub mod config_toml; @@ -25,19 +25,25 @@ pub mod schema; mod skills_config; mod state; mod strict_config; +pub mod test_support; mod thread_config; mod tui_keymap; pub mod types; pub const CONFIG_TOML_FILE: &str = "config.toml"; +pub use cloud_config_bundle::CloudConfigBundle; +pub use cloud_config_bundle::CloudConfigBundleLayers; +pub use cloud_config_bundle::CloudConfigBundleLoadError; +pub use cloud_config_bundle::CloudConfigBundleLoadErrorCode; +pub use cloud_config_bundle::CloudConfigBundleLoader; +pub use cloud_config_bundle::CloudConfigTomlBundle; +pub use cloud_config_bundle::CloudRequirementsFragment; +pub use cloud_config_bundle::CloudRequirementsTomlBundle; pub use cloud_config_layers::CloudConfigFragment; pub use cloud_config_layers::CloudConfigFragmentSource; pub use cloud_config_layers::CloudConfigLayerError; pub use cloud_config_layers::cloud_config_layers_from_fragments; -pub use cloud_requirements::CloudRequirementsLoadError; -pub use cloud_requirements::CloudRequirementsLoadErrorCode; -pub use cloud_requirements::CloudRequirementsLoader; pub use codex_app_server_protocol::ConfigLayerSource; pub use codex_protocol::config_types::ProfileV2Name; pub use codex_protocol::config_types::ProfileV2NameParseError; diff --git a/codex-rs/config/src/loader/README.md b/codex-rs/config/src/loader/README.md index e004cea87c..b711ebbb61 100644 --- a/codex-rs/config/src/loader/README.md +++ b/codex-rs/config/src/loader/README.md @@ -1,6 +1,6 @@ # `codex-config` loader -This module is the canonical place to **load and describe Codex configuration layers** (user config, CLI/session overrides, managed config, and MDM-managed preferences) and to produce: +This module is the canonical place to **load and describe Codex configuration layers** (user config, CLI/session overrides, cloud-managed config, managed config, and MDM-managed preferences) and to produce: - An **effective merged** TOML config. - **Per-key origins** metadata (which layer “wins” for a given key). @@ -10,7 +10,7 @@ This module is the canonical place to **load and describe Codex configuration la Exported from `codex_config::loader`: -- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, options, cloud_requirements, thread_config_loader) -> ConfigLayerStack` +- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, options, thread_config_loader) -> ConfigLayerStack` - `ConfigLayerStack` - `effective_config() -> toml::Value` - `origins() -> HashMap` @@ -25,13 +25,19 @@ Exported from `codex_config::loader`: Precedence is **top overrides bottom**: -1. **MDM** managed preferences (macOS only) -2. **System** managed config (e.g. `managed_config.toml`) -3. **Session flags** (CLI overrides, applied as dotted-path TOML writes) -4. **User** config (`config.toml`) +1. `LegacyManagedConfigTomlFromMdm` (MDM-delivered `managed_config.toml`, while it is being phased out) +2. `LegacyManagedConfigTomlFromFile` (`managed_config.toml`, while it is being phased out) +3. `SessionFlags` (CLI overrides, applied as dotted-path TOML writes) +4. `Project` config (`.codex/config.toml`) +5. `User` profile config, when present +6. `User` config (`config.toml`) +7. `EnterpriseManaged` cloud-managed config bundle layers +8. `System` config (`/etc/codex/config.toml` or the Windows system config path) -Thread config entries supplied by `thread_config_loader` are inserted according -to their translated `ConfigLayerSource` precedence. +`ConfigLayerStack` stores layers in the opposite order internally: lowest +precedence first, highest precedence last, so later layers override earlier +layers when folded. Thread config entries supplied by `thread_config_loader` are +inserted according to their translated `ConfigLayerSource` precedence. Layers with a `disabled_reason` are still surfaced for UI, but are ignored when computing the effective config and origins metadata. This is what @@ -42,9 +48,8 @@ computing the effective config and origins metadata. This is what Most callers want the effective config plus metadata: ```rust -use codex_config::NoopThreadConfigLoader; -use codex_config::CloudRequirementsLoader; use codex_config::LoaderOverrides; +use codex_config::NoopThreadConfigLoader; use codex_config::loader::load_config_layers_state; use codex_exec_server::LOCAL_FS; use codex_utils_absolute_path::AbsolutePathBuf; @@ -58,7 +63,6 @@ let layers = load_config_layers_state( Some(cwd), &cli_overrides, LoaderOverrides::default(), - CloudRequirementsLoader::default(), &NoopThreadConfigLoader, ).await?; diff --git a/codex-rs/config/src/loader/macos.rs b/codex-rs/config/src/loader/macos.rs index 199e7f6daa..974cb27604 100644 --- a/codex-rs/config/src/loader/macos.rs +++ b/codex-rs/config/src/loader/macos.rs @@ -1,6 +1,4 @@ -use super::merge_requirements_with_remote_sandbox_config; -use crate::config_requirements::ConfigRequirementsToml; -use crate::config_requirements::ConfigRequirementsWithSources; +use crate::RequirementsLayerEntry; use crate::config_requirements::RequirementSource; use crate::config_toml::ConfigToml; use crate::diagnostics::ConfigDiagnosticSource; @@ -75,35 +73,27 @@ fn load_managed_admin_config( .transpose() } -pub(crate) async fn load_managed_admin_requirements_toml( - target: &mut ConfigRequirementsWithSources, +pub(crate) async fn load_managed_admin_requirements_layer( override_base64: Option<&str>, -) -> io::Result<()> { +) -> io::Result> { if let Some(encoded) = override_base64 { let trimmed = encoded.trim(); if trimmed.is_empty() { - return Ok(()); + return Ok(None); } - merge_requirements_with_remote_sandbox_config( - target, - managed_preferences_requirements_source(), - parse_managed_requirements_base64(trimmed)?, - ); - return Ok(()); + return parse_managed_requirements_base64(trimmed).map(|contents| { + Some(RequirementsLayerEntry::from_toml( + managed_preferences_requirements_source(), + contents, + )) + }); } match task::spawn_blocking(load_managed_admin_requirements).await { - Ok(result) => { - if let Some(requirements) = result? { - merge_requirements_with_remote_sandbox_config( - target, - managed_preferences_requirements_source(), - requirements, - ); - } - Ok(()) - } + Ok(result) => Ok(result?.map(|contents| { + RequirementsLayerEntry::from_toml(managed_preferences_requirements_source(), contents) + })), Err(join_err) => { if join_err.is_cancelled() { tracing::error!("Managed requirements load task was cancelled"); @@ -115,7 +105,7 @@ pub(crate) async fn load_managed_admin_requirements_toml( } } -fn load_managed_admin_requirements() -> io::Result> { +fn load_managed_admin_requirements() -> io::Result> { load_managed_preference(MANAGED_PREFERENCES_REQUIREMENTS_KEY)? .as_deref() .map(str::trim) @@ -217,13 +207,8 @@ fn validate_managed_config_toml_strictly_if_requested( } } -fn parse_managed_requirements_base64(encoded: &str) -> io::Result { - toml::from_str::(&decode_managed_preferences_base64(encoded)?).map_err( - |err| { - tracing::error!("Failed to parse managed requirements TOML: {err}"); - io::Error::new(io::ErrorKind::InvalidData, err) - }, - ) +fn parse_managed_requirements_base64(encoded: &str) -> io::Result { + decode_managed_preferences_base64(encoded) } fn decode_managed_preferences_base64(encoded: &str) -> io::Result { diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index a657af0f36..bc0fb482e4 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -6,10 +6,10 @@ mod tests; use self::layer_io::LoadedConfigLayers; use crate::CONFIG_TOML_FILE; +use crate::CloudConfigBundleLayers; use crate::ProfileV2Name; -use crate::cloud_requirements::CloudRequirementsLoader; -use crate::config_requirements::ConfigRequirementsToml; -use crate::config_requirements::ConfigRequirementsWithSources; +use crate::RequirementsLayerEntry; +use crate::compose_requirements; use crate::config_requirements::RequirementSource; use crate::config_requirements::SandboxModeRequirement; use crate::config_toml::ConfigToml; @@ -75,14 +75,16 @@ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> O typed_first_layer_config_error_from_entries::(layers, CONFIG_TOML_FILE).await } -/// To build up the set of admin-enforced constraints, we build up from multiple -/// configuration layers in the following order, but a constraint defined in an -/// earlier layer cannot be overridden by a later layer: +/// To build up the set of admin-enforced constraints, requirements layers are +/// collected in ascending precedence order, matching config layers, and then +/// composed with config-style TOML merging plus field-specific handling for +/// hooks, rules, deny-read permissions, and remote sandbox config: /// -/// - cloud: managed cloud requirements -/// - admin: managed preferences (*) /// - system `/etc/codex/requirements.toml` (Unix) or /// `%ProgramData%\OpenAI\Codex\requirements.toml` (Windows) +/// - cloud: enterprise-managed cloud config bundle requirements +/// - legacy: managed_config.toml reinterpreted as requirements.toml +/// - admin: managed preferences (*) /// /// For backwards compatibility, we also load from /// `managed_config.toml` and map it to `requirements.toml`. @@ -92,6 +94,7 @@ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> O /// - admin: managed preferences (*) /// - system `/etc/codex/config.toml` (Unix) or /// `%ProgramData%\OpenAI\Codex\config.toml` (Windows) +/// - cloud enterprise-managed cloud config bundle fragments /// - user `${CODEX_HOME}/config.toml` /// - profile `${CODEX_HOME}/.config.toml`, when selected /// - cwd `${PWD}/config.toml` (loaded but disabled when the directory is untrusted) @@ -114,56 +117,77 @@ pub async fn load_config_layers_state( cwd: Option, cli_overrides: &[(String, TomlValue)], options: impl Into, - cloud_requirements: CloudRequirementsLoader, thread_config_loader: &dyn ThreadConfigLoader, ) -> io::Result { let ConfigLoadOptions { loader_overrides: overrides, strict_config, + cloud_config_bundle, } = options.into(); let active_user_profile = overrides.user_config_profile.clone(); let ignore_managed_requirements = overrides.ignore_managed_requirements; let ignore_user_config = overrides.ignore_user_config; let ignore_user_and_project_exec_policy_rules = overrides.ignore_user_and_project_exec_policy_rules; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); + let mut requirements_layers = Vec::new(); + let mut bundle_requirements_layers = Vec::new(); + let mut system_requirements_layer = None; + let managed_preferences_requirements_layer; + let mut cloud_config_layers = Vec::new(); if !ignore_managed_requirements { - if let Some(requirements) = cloud_requirements.get().await.map_err(io::Error::other)? { - merge_requirements_with_remote_sandbox_config( - &mut config_requirements_toml, - RequirementSource::CloudRequirements, - requirements, - ); + if let Some(bundle) = cloud_config_bundle.get().await.map_err(io::Error::other)? { + let cloud_config_base_dir = AbsolutePathBuf::from_absolute_path(codex_home)?; + let bundle_layers = if strict_config { + CloudConfigBundleLayers::from_bundle_strict_config(bundle, &cloud_config_base_dir)? + } else { + CloudConfigBundleLayers::from_bundle(bundle, &cloud_config_base_dir)? + }; + let CloudConfigBundleLayers { + enterprise_managed_config, + enterprise_managed_requirements, + } = bundle_layers; + bundle_requirements_layers = enterprise_managed_requirements; + cloud_config_layers = enterprise_managed_config; } #[cfg(target_os = "macos")] - macos::load_managed_admin_requirements_toml( - &mut config_requirements_toml, - overrides - .macos_managed_config_requirements_base64 - .as_deref(), - ) - .await?; + { + managed_preferences_requirements_layer = macos::load_managed_admin_requirements_layer( + overrides + .macos_managed_config_requirements_base64 + .as_deref(), + ) + .await?; + } + #[cfg(not(target_os = "macos"))] + { + managed_preferences_requirements_layer = None; + } // Honor the system requirements.toml location. let requirements_toml_file = system_requirements_toml_file_with_overrides(&overrides)?; - load_requirements_toml(fs, &mut config_requirements_toml, &requirements_toml_file).await?; + system_requirements_layer = load_requirements_toml(fs, &requirements_toml_file).await?; + } else { + managed_preferences_requirements_layer = None; } - // Make a best-effort to support the legacy `managed_config.toml` as a - // requirements specification. let loaded_config_layers = layer_io::load_config_layers_internal(fs, codex_home, overrides.clone(), strict_config) .await?; if !ignore_managed_requirements { - load_requirements_from_legacy_scheme( - &mut config_requirements_toml, + requirements_layers.extend(system_requirements_layer); + requirements_layers.extend(bundle_requirements_layers); + // Continue to support the legacy `managed_config.toml` locations as + // requirements layers for backwards compatibility. + requirements_layers.extend(requirements_layers_from_legacy_scheme( loaded_config_layers.clone(), - ) - .await?; + )?); + requirements_layers.extend(managed_preferences_requirements_layer); } + let config_requirements_toml = compose_requirements(requirements_layers)?.unwrap_or_default(); + let thread_config_context = ThreadConfigContext { thread_id: None, cwd: cwd.clone(), @@ -210,6 +234,7 @@ pub async fn load_config_layers_state( ) .await?; layers.push(system_layer); + layers.extend(cloud_config_layers); // Add the base user config layer. When profile-v2 is selected, add the // profile config as a second user layer on top so the profile only needs to @@ -535,15 +560,12 @@ fn validate_cli_overrides_strictly( Ok(()) } -/// If available, apply requirements from the platform system -/// `requirements.toml` location to `config_requirements_toml` by filling in -/// any unset fields. -#[doc(hidden)] +/// If available, load requirements from the platform's system `requirements.toml` +/// location as a requirements layer. pub async fn load_requirements_toml( fs: &dyn ExecutorFileSystem, - config_requirements_toml: &mut ConfigRequirementsWithSources, requirements_toml_file: &AbsolutePathBuf, -) -> io::Result<()> { +) -> io::Result> { match fs .read_file_text(requirements_toml_file, /*sandbox*/ None) .await @@ -558,39 +580,31 @@ pub async fn load_requirements_toml( ), ) })?; - let _guard = AbsolutePathBufGuard::new(requirements_parent.as_path()); - let requirements_config: ConfigRequirementsToml = - toml::from_str(&contents).map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "Error parsing requirements file {}: {e}", - requirements_toml_file.as_path().display(), - ), - ) - })?; - merge_requirements_with_remote_sandbox_config( - config_requirements_toml, - RequirementSource::SystemRequirementsToml { - file: requirements_toml_file.clone(), - }, - requirements_config, - ); + let base_dir = AbsolutePathBuf::from_absolute_path(requirements_parent)?; + Ok(Some( + RequirementsLayerEntry::from_toml( + RequirementSource::SystemRequirementsToml { + file: requirements_toml_file.clone(), + }, + contents, + ) + .with_base_dir(base_dir), + )) } Err(e) => { if e.kind() != io::ErrorKind::NotFound { - return Err(io::Error::new( + Err(io::Error::new( e.kind(), format!( "Failed to read requirements file {}: {e}", requirements_toml_file.as_path().display(), ), - )); + )) + } else { + Ok(None) } } } - - Ok(()) } #[cfg(unix)] @@ -704,31 +718,33 @@ fn windows_program_data_dir_from_known_folder() -> io::Result { Ok(path) } -async fn load_requirements_from_legacy_scheme( - config_requirements_toml: &mut ConfigRequirementsWithSources, +fn requirements_layers_from_legacy_scheme( loaded_config_layers: LoadedConfigLayers, -) -> io::Result<()> { - // In this implementation, earlier layers cannot be overwritten by later - // layers, so list managed_config_from_mdm first because it has the highest - // precedence. +) -> io::Result> { + // List the file-backed legacy layer first because requirements layers are + // composed lowest-precedence to highest-precedence, and MDM has higher + // precedence than the legacy managed_config.toml file. let LoadedConfigLayers { managed_config, managed_config_from_mdm, } = loaded_config_layers; - for (source, config) in managed_config_from_mdm - .map(|config| { - ( - RequirementSource::LegacyManagedConfigTomlFromMdm, - config.managed_config, - ) - }) - .into_iter() - .chain(managed_config.map(|c| { + let layer_count = + usize::from(managed_config.is_some()) + usize::from(managed_config_from_mdm.is_some()); + let mut layers = Vec::with_capacity(layer_count); + for (source, config) in managed_config + .map(|c| { ( RequirementSource::LegacyManagedConfigTomlFromFile { file: c.file }, c.managed_config, ) + }) + .into_iter() + .chain(managed_config_from_mdm.map(|config| { + ( + RequirementSource::LegacyManagedConfigTomlFromMdm, + config.managed_config, + ) })) { let legacy_config: LegacyManagedConfigToml = @@ -739,26 +755,56 @@ async fn load_requirements_from_legacy_scheme( ) })?; - merge_requirements_with_remote_sandbox_config( - config_requirements_toml, + layers.push(RequirementsLayerEntry::from_toml_value( source, - ConfigRequirementsToml::from(legacy_config), - ); + legacy_requirements_to_toml_value(legacy_config)?, + )); } - Ok(()) + Ok(layers) } -pub(super) fn merge_requirements_with_remote_sandbox_config( - target: &mut ConfigRequirementsWithSources, - source: RequirementSource, - mut requirements: ConfigRequirementsToml, -) { - if requirements.remote_sandbox_config.is_some() { - let host_name = crate::host_name(); - requirements.apply_remote_sandbox_config(host_name.as_deref()); +fn legacy_requirements_to_toml_value(legacy: LegacyManagedConfigToml) -> io::Result { + let LegacyManagedConfigToml { + approval_policy, + approvals_reviewer, + sandbox_mode, + } = legacy; + let mut table = toml::map::Map::new(); + if let Some(approval_policy) = approval_policy { + table.insert( + "allowed_approval_policies".to_string(), + toml_value_from_serializable(vec![approval_policy])?, + ); } - target.merge_unset_fields(source, requirements); + if let Some(approvals_reviewer) = approvals_reviewer { + let mut allowed_reviewers = vec![approvals_reviewer]; + if approvals_reviewer == ApprovalsReviewer::AutoReview { + allowed_reviewers.push(ApprovalsReviewer::User); + } + table.insert( + "allowed_approvals_reviewers".to_string(), + toml_value_from_serializable(allowed_reviewers)?, + ); + } + if let Some(sandbox_mode) = sandbox_mode { + let required_mode: SandboxModeRequirement = sandbox_mode.into(); + // Allowing read-only is a requirement for Codex to function correctly. + // So in this backfill path, we append read-only if it's not already specified. + let mut allowed_modes = vec![SandboxModeRequirement::ReadOnly]; + if required_mode != SandboxModeRequirement::ReadOnly { + allowed_modes.push(required_mode); + } + table.insert( + "allowed_sandbox_modes".to_string(), + toml_value_from_serializable(allowed_modes)?, + ); + } + Ok(TomlValue::Table(table)) +} + +fn toml_value_from_serializable(value: T) -> io::Result { + TomlValue::try_from(value).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) } struct ProjectTrustContext { @@ -1342,39 +1388,6 @@ struct LegacyManagedConfigToml { sandbox_mode: Option, } -impl From for ConfigRequirementsToml { - fn from(legacy: LegacyManagedConfigToml) -> Self { - let mut config_requirements_toml = ConfigRequirementsToml::default(); - - let LegacyManagedConfigToml { - approval_policy, - approvals_reviewer, - sandbox_mode, - } = legacy; - if let Some(approval_policy) = approval_policy { - config_requirements_toml.allowed_approval_policies = Some(vec![approval_policy]); - } - if let Some(approvals_reviewer) = approvals_reviewer { - let mut allowed_reviewers = vec![approvals_reviewer]; - if approvals_reviewer == ApprovalsReviewer::AutoReview { - allowed_reviewers.push(ApprovalsReviewer::User); - } - config_requirements_toml.allowed_approvals_reviewers = Some(allowed_reviewers); - } - if let Some(sandbox_mode) = sandbox_mode { - let required_mode: SandboxModeRequirement = sandbox_mode.into(); - // Allowing read-only is a requirement for Codex to function correctly. - // So in this backfill path, we append read-only if it's not already specified. - let mut allowed_modes = vec![SandboxModeRequirement::ReadOnly]; - if required_mode != SandboxModeRequirement::ReadOnly { - allowed_modes.push(required_mode); - } - config_requirements_toml.allowed_sandbox_modes = Some(allowed_modes); - } - config_requirements_toml - } -} - // Cannot name this `mod tests` because of tests.rs in this folder. #[cfg(test)] mod unit_tests { @@ -1421,54 +1434,63 @@ foo = "xyzzy" } #[test] - fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() { + fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() -> io::Result<()> { let legacy = LegacyManagedConfigToml { approval_policy: None, approvals_reviewer: None, sandbox_mode: Some(SandboxMode::WorkspaceWrite), }; - let requirements = ConfigRequirementsToml::from(legacy); - assert_eq!( - requirements.allowed_sandbox_modes, - Some(vec![ - SandboxModeRequirement::ReadOnly, - SandboxModeRequirement::WorkspaceWrite - ]) + legacy_requirements_to_toml_value(legacy)?, + TomlValue::Table(toml::map::Map::from_iter([( + "allowed_sandbox_modes".to_string(), + TomlValue::Array(vec![ + TomlValue::String("read-only".to_string()), + TomlValue::String("workspace-write".to_string()), + ]), + )])) ); + Ok(()) } #[test] - fn legacy_managed_config_backfill_allows_user_when_guardian_is_required() { + fn legacy_managed_config_backfill_allows_user_when_guardian_is_required() -> io::Result<()> { let legacy = LegacyManagedConfigToml { approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::AutoReview), sandbox_mode: None, }; - let requirements = ConfigRequirementsToml::from(legacy); - assert_eq!( - requirements.allowed_approvals_reviewers, - Some(vec![ApprovalsReviewer::AutoReview, ApprovalsReviewer::User,]) + legacy_requirements_to_toml_value(legacy)?, + TomlValue::Table(toml::map::Map::from_iter([( + "allowed_approvals_reviewers".to_string(), + TomlValue::Array(vec![ + TomlValue::String("guardian_subagent".to_string()), + TomlValue::String("user".to_string()), + ]), + )])) ); + Ok(()) } #[test] - fn legacy_managed_config_backfill_preserves_user_only_approvals_reviewer() { + fn legacy_managed_config_backfill_preserves_user_only_approvals_reviewer() -> io::Result<()> { let legacy = LegacyManagedConfigToml { approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_mode: None, }; - let requirements = ConfigRequirementsToml::from(legacy); - assert_eq!( - requirements.allowed_approvals_reviewers, - Some(vec![ApprovalsReviewer::User]) + legacy_requirements_to_toml_value(legacy)?, + TomlValue::Table(toml::map::Map::from_iter([( + "allowed_approvals_reviewers".to_string(), + TomlValue::Array(vec![TomlValue::String("user".to_string())]), + )])) ); + Ok(()) } #[cfg(windows)] diff --git a/codex-rs/config/src/loader/tests.rs b/codex-rs/config/src/loader/tests.rs index 9d4511a8a6..6492f9a0c0 100644 --- a/codex-rs/config/src/loader/tests.rs +++ b/codex-rs/config/src/loader/tests.rs @@ -128,7 +128,6 @@ model = "gpt-work" /*cwd*/ None, &[], overrides, - CloudRequirementsLoader::default(), &crate::NoopThreadConfigLoader, ) .await @@ -187,7 +186,6 @@ model = "gpt-main" /*cwd*/ None, &[], overrides, - CloudRequirementsLoader::default(), &crate::NoopThreadConfigLoader, ) .await @@ -244,7 +242,6 @@ model = "gpt-dev" /*cwd*/ None, &[], overrides, - CloudRequirementsLoader::default(), &crate::NoopThreadConfigLoader, ) .await diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index e4d03c3d3c..d5f2b050c9 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -5,6 +5,7 @@ use super::fingerprint::record_origins; use super::fingerprint::version_for_toml; use super::key_aliases::normalized_with_key_aliases; use super::merge::merge_toml_values; +use crate::CloudConfigBundleLoader; use crate::ProfileV2Name; use codex_app_server_protocol::ConfigLayer; use codex_app_server_protocol::ConfigLayerMetadata; @@ -21,6 +22,7 @@ use toml::Value as TomlValue; pub struct ConfigLoadOptions { pub loader_overrides: LoaderOverrides, pub strict_config: bool, + pub cloud_config_bundle: CloudConfigBundleLoader, } impl From for ConfigLoadOptions { @@ -28,6 +30,7 @@ impl From for ConfigLoadOptions { Self { loader_overrides, strict_config: false, + cloud_config_bundle: CloudConfigBundleLoader::default(), } } } @@ -471,8 +474,8 @@ impl ConfigLayerStack { /// Returns the merged config-layer view. /// - /// This only merges ordinary config layers and does not apply requirements - /// such as cloud requirements. + /// This only merges ordinary config layers. Requirements are composed and + /// tracked separately. pub fn effective_config(&self) -> TomlValue { let mut merged = TomlValue::Table(toml::map::Map::new()); for layer in self.get_layers( diff --git a/codex-rs/config/src/strict_config.rs b/codex-rs/config/src/strict_config.rs index fb64458e07..c1523c706c 100644 --- a/codex-rs/config/src/strict_config.rs +++ b/codex-rs/config/src/strict_config.rs @@ -37,7 +37,6 @@ pub(crate) fn config_error_from_ignored_toml_value_fields( ) } -#[cfg(any(target_os = "macos", test))] pub(crate) fn config_error_from_ignored_toml_value_fields_for_source_name( source_name: &str, contents: &str, diff --git a/codex-rs/config/src/test_support.rs b/codex-rs/config/src/test_support.rs new file mode 100644 index 0000000000..44627f3892 --- /dev/null +++ b/codex-rs/config/src/test_support.rs @@ -0,0 +1,80 @@ +//! Test-only helpers exposed for cross-crate integration tests. +//! +//! Production code should not depend on this module. + +use crate::CloudConfigBundle; +use crate::CloudConfigBundleLoader; +use crate::CloudConfigFragment; +use crate::CloudRequirementsFragment; + +#[derive(Debug, Clone, Default)] +pub struct CloudConfigBundleFixture { + bundle: CloudConfigBundle, +} + +impl CloudConfigBundleFixture { + pub fn enterprise_requirement(contents: impl Into) -> Self { + Self::default().add_enterprise_requirement(contents) + } + + pub fn enterprise_config(contents: impl Into) -> Self { + Self::default().add_enterprise_config(contents) + } + + pub fn loader_with_enterprise_requirement( + contents: impl Into, + ) -> CloudConfigBundleLoader { + Self::enterprise_requirement(contents).into_loader() + } + + pub fn loader_with_enterprise_config(contents: impl Into) -> CloudConfigBundleLoader { + Self::enterprise_config(contents).into_loader() + } + + pub fn add_enterprise_requirement(mut self, contents: impl Into) -> Self { + let index = self.bundle.requirements_toml.enterprise_managed.len() + 1; + self.bundle + .requirements_toml + .enterprise_managed + .push(CloudRequirementsFragment { + id: format!("req_{index}"), + name: if index == 1 { + "Base requirements".to_string() + } else { + format!("Requirements {index}") + }, + contents: contents.into(), + }); + self + } + + pub fn add_enterprise_config(mut self, contents: impl Into) -> Self { + let index = self.bundle.config_toml.enterprise_managed.len() + 1; + self.bundle + .config_toml + .enterprise_managed + .push(CloudConfigFragment { + id: format!("cfg_{index}"), + name: if index == 1 { + "Base config".to_string() + } else { + format!("Config {index}") + }, + contents: contents.into(), + }); + self + } + + pub fn into_bundle(self) -> CloudConfigBundle { + self.bundle + } + + pub fn into_loader(self) -> CloudConfigBundleLoader { + let bundle = self.into_bundle(); + CloudConfigBundleLoader::new(async move { Ok(Some(bundle)) }) + } +} + +#[cfg(test)] +#[path = "test_support_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/test_support_tests.rs b/codex-rs/config/src/test_support_tests.rs new file mode 100644 index 0000000000..379d6c2be2 --- /dev/null +++ b/codex-rs/config/src/test_support_tests.rs @@ -0,0 +1,25 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn adds_enterprise_requirements_in_order() { + let bundle = CloudConfigBundleFixture::enterprise_requirement("first") + .add_enterprise_requirement("second") + .into_bundle(); + + assert_eq!( + bundle.requirements_toml.enterprise_managed, + vec![ + CloudRequirementsFragment { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + contents: "first".to_string(), + }, + CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Requirements 2".to_string(), + contents: "second".to_string(), + }, + ] + ); +} diff --git a/codex-rs/core-plugins/src/test_support.rs b/codex-rs/core-plugins/src/test_support.rs index 9e37a14312..07f1fd4f26 100644 --- a/codex-rs/core-plugins/src/test_support.rs +++ b/codex-rs/core-plugins/src/test_support.rs @@ -3,7 +3,6 @@ use std::path::Path; use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::PluginsConfigInput; -use codex_config::CloudRequirementsLoader; use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; use codex_config::loader::load_config_layers_state; @@ -106,7 +105,6 @@ pub(crate) async fn load_plugins_config(codex_home: &Path, cwd: &Path) -> Plugin Some(cwd), &[], LoaderOverrides::without_managed_config_for_tests(), - CloudRequirementsLoader::default(), &NoopThreadConfigLoader, ) .await diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index d0097d7537..5edecebec0 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -3,27 +3,31 @@ use crate::config::ConfigOverrides; use crate::config::ConstraintError; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; -use codex_config::CloudRequirementsLoadError; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigError; use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerStackOrdering; use codex_config::ConfigLoadError; +use codex_config::ConfigLoadOptions; use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; use codex_config::ConfigRequirementsWithSources; use codex_config::FilesystemDenyReadPattern; use codex_config::LoaderOverrides; use codex_config::RequirementSource; +use codex_config::RequirementsLayerEntry; use codex_config::SessionThreadConfig; use codex_config::StaticThreadConfigLoader; use codex_config::ThreadConfigSource; +use codex_config::compose_requirements; use codex_config::config_error_from_ignored_toml_fields; use codex_config::config_error_from_toml; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_config::loader::load_config_layers_state; use codex_config::loader::load_requirements_toml; +use codex_config::test_support::CloudConfigBundleFixture; use codex_exec_server::LOCAL_FS; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; @@ -46,6 +50,22 @@ fn config_error_from_io(err: &std::io::Error) -> &ConfigError { .expect("expected ConfigLoadError") } +fn cloud_config_bundle_requirement_source() -> RequirementSource { + RequirementSource::EnterpriseManaged { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + } +} + +async fn load_single_requirements_toml( + requirements_file: &AbsolutePathBuf, +) -> anyhow::Result { + let layer = load_requirements_toml(LOCAL_FS.as_ref(), requirements_file) + .await? + .expect("requirements.toml should load"); + Ok(compose_requirements(vec![layer])?.expect("requirements should be present")) +} + async fn make_config_for_test( codex_home: &Path, project_path: &Path, @@ -147,7 +167,6 @@ invalid = ["#; Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -179,7 +198,6 @@ invalid = ["#, ignore_user_config: true, ..Default::default() }, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -210,7 +228,6 @@ async fn ignore_rules_marks_config_stack_for_exec_policy_rule_skip() -> std::io: ignore_user_and_project_exec_policy_rules: true, ..Default::default() }, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -236,7 +253,6 @@ invalid = ["#; Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -288,7 +304,6 @@ async fn top_level_allow_managed_hooks_only_in_user_config_does_not_enable_requi Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -323,7 +338,6 @@ command = "python3 /tmp/user-hook.py" Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -533,7 +547,6 @@ extra = true Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -567,7 +580,6 @@ async fn returns_empty_when_all_layers_missing() { Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -644,7 +656,6 @@ approval_policy = "on-failure" Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -707,7 +718,6 @@ async fn includes_thread_config_layers_in_stack() -> anyhow::Result<()> { Some(cwd), &[("features.plugins".to_string(), TomlValue::Boolean(true))], overrides, - CloudRequirementsLoader::default(), &StaticThreadConfigLoader::new(vec![ThreadConfigSource::Session(SessionThreadConfig { features: BTreeMap::from([("plugins".to_string(), false)]), ..Default::default() @@ -787,7 +797,6 @@ flag = false Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -891,7 +900,6 @@ allowed_sandbox_modes = ["read-only"] Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], loader_overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -953,7 +961,6 @@ allowed_approval_policies = ["never"] Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], loader_overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -990,14 +997,8 @@ personality = true ) .await?; - let requirements_file = AbsolutePathBuf::try_from(requirements_file)?; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - load_requirements_toml( - LOCAL_FS.as_ref(), - &mut config_requirements_toml, - &requirements_file, - ) - .await?; + let config_requirements_toml = + load_single_requirements_toml(&AbsolutePathBuf::try_from(requirements_file)?).await?; assert_eq!( config_requirements_toml @@ -1073,7 +1074,7 @@ personality = true #[cfg(target_os = "macos")] #[tokio::test] -async fn cloud_requirements_take_precedence_over_mdm_requirements() -> anyhow::Result<()> { +async fn mdm_requirements_take_precedence_over_cloud_config_bundle() -> anyhow::Result<()> { use base64::Engine; let tmp = tempdir()?; @@ -1091,49 +1092,34 @@ allowed_approval_policies = ["on-request"] tmp.path(), Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], - loader_overrides, - CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - guardian_policy_config: None, - })) - }), + ConfigLoadOptions { + loader_overrides, + cloud_config_bundle: CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["never"]"#, + ), + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await?; assert_eq!( state.requirements().approval_policy.value(), - AskForApproval::Never + AskForApproval::OnRequest ); assert_eq!( state .requirements() .approval_policy - .can_set(&AskForApproval::OnRequest), + .can_set(&AskForApproval::Never), Err(ConstraintError::InvalidValue { field_name: "approval_policy", - candidate: "OnRequest".into(), - allowed: "[Never]".into(), - requirement_source: RequirementSource::CloudRequirements, + candidate: "Never".into(), + allowed: "[OnRequest]".into(), + requirement_source: RequirementSource::MdmManagedPreferences { + domain: "com.openai.codex".to_string(), + key: "requirements_toml_base64".to_string(), + }, }) ); @@ -1141,7 +1127,7 @@ allowed_approval_policies = ["on-request"] } #[tokio::test(flavor = "current_thread")] -async fn cloud_requirements_are_not_overwritten_by_system_requirements() -> anyhow::Result<()> { +async fn cloud_config_bundle_are_not_overwritten_by_system_requirements() -> anyhow::Result<()> { let tmp = tempdir()?; let requirements_file = tmp.path().join("requirements.toml"); tokio::fs::write( @@ -1152,38 +1138,20 @@ allowed_approval_policies = ["on-request"] ) .await?; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - config_requirements_toml.merge_unset_fields( - RequirementSource::CloudRequirements, - ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - guardian_policy_config: None, - }, - ); - load_requirements_toml( + let system_layer = load_requirements_toml( LOCAL_FS.as_ref(), - &mut config_requirements_toml, &AbsolutePathBuf::try_from(requirements_file)?, ) - .await?; + .await? + .expect("system requirements should load"); + let config_requirements_toml = compose_requirements(vec![ + system_layer, + RequirementsLayerEntry::from_toml( + cloud_config_bundle_requirement_source(), + r#"allowed_approval_policies = ["never"]"#, + ), + ])? + .expect("requirements should be present"); assert_eq!( config_requirements_toml @@ -1197,7 +1165,7 @@ allowed_approval_policies = ["on-request"] .allowed_approval_policies .as_ref() .map(|sourced| sourced.source.clone()), - Some(RequirementSource::CloudRequirements) + Some(cloud_config_bundle_requirement_source()) ); Ok(()) @@ -1217,22 +1185,21 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] ) .await?; - let cloud_source = RequirementSource::CloudRequirements; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - config_requirements_toml.merge_unset_fields( - cloud_source.clone(), - toml::from_str( - r#" -allowed_sandbox_modes = ["read-only"] -"#, - )?, - ); - load_requirements_toml( + let cloud_source = cloud_config_bundle_requirement_source(); + let system_layer = load_requirements_toml( LOCAL_FS.as_ref(), - &mut config_requirements_toml, &AbsolutePathBuf::try_from(requirements_file)?, ) - .await?; + .await? + .expect("system requirements should load"); + let config_requirements_toml = compose_requirements(vec![ + system_layer, + RequirementsLayerEntry::from_toml( + cloud_source.clone(), + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ])? + .expect("requirements should be present"); let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?; assert_eq!( @@ -1266,13 +1233,7 @@ deny_read = ["./sensitive", "../shared/secret.txt"] .await?; let requirements_file = AbsolutePathBuf::try_from(requirements_file)?; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - load_requirements_toml( - LOCAL_FS.as_ref(), - &mut config_requirements_toml, - &requirements_file, - ) - .await?; + let config_requirements_toml = load_single_requirements_toml(&requirements_file).await?; let permissions = config_requirements_toml .permissions @@ -1320,13 +1281,7 @@ deny_read = ["./sensitive/**/*.txt"] .await?; let requirements_file = AbsolutePathBuf::try_from(requirements_file)?; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - load_requirements_toml( - LOCAL_FS.as_ref(), - &mut config_requirements_toml, - &requirements_file, - ) - .await?; + let config_requirements_toml = load_single_requirements_toml(&requirements_file).await?; let permissions = config_requirements_toml .permissions @@ -1360,44 +1315,26 @@ deny_read = ["./sensitive/**/*.txt"] } #[tokio::test] -async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> { +async fn load_config_layers_includes_cloud_config_bundle() -> anyhow::Result<()> { let tmp = tempdir()?; let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; - let requirements = ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - guardian_policy_config: None, - }; - let expected = requirements.clone(); - let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) }); + let requirements = r#"allowed_approval_policies = ["never"]"#; + let expected: ConfigRequirementsToml = toml::from_str(requirements)?; + let cloud_config_bundle = + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements); let layers = load_config_layers_state( LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], - LoaderOverrides::default(), - cloud_requirements, + ConfigLoadOptions { + cloud_config_bundle, + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1415,7 +1352,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> field_name: "approval_policy", candidate: "OnRequest".into(), allowed: "[Never]".into(), - requirement_source: RequirementSource::CloudRequirements, + requirement_source: cloud_config_bundle_requirement_source(), }) ); @@ -1680,6 +1617,88 @@ extends = ":workspace" Ok(()) } +#[tokio::test] +async fn load_config_layers_inserts_cloud_config_between_system_and_user() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"model = "user" +"#, + ) + .await?; + + let system_config_path = tmp.path().join("system_config.toml"); + tokio::fs::write( + &system_config_path, + r#"model = "system" +model_provider = "system-provider" +review_model = "system-review" +"#, + ) + .await?; + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_config_path = Some(system_config_path.clone()); + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + ConfigLoadOptions { + loader_overrides: overrides, + cloud_config_bundle: CloudConfigBundleFixture::loader_with_enterprise_config( + r#"model = "cloud" +model_provider = "cloud-provider" +"#, + ), + ..Default::default() + }, + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + let merged = layers.effective_config(); + let table = merged.as_table().expect("merged config should be a table"); + assert_eq!(table.get("model"), Some(&TomlValue::String("user".into()))); + assert_eq!( + table.get("model_provider"), + Some(&TomlValue::String("cloud-provider".into())) + ); + assert_eq!( + table.get("review_model"), + Some(&TomlValue::String("system-review".into())) + ); + assert_eq!( + layers + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ) + .iter() + .map(|layer| layer.name.clone()) + .collect::>(), + vec![ + ConfigLayerSource::System { + file: AbsolutePathBuf::from_absolute_path(&system_config_path)?, + }, + ConfigLayerSource::EnterpriseManaged { + id: "cfg_1".to_string(), + name: "Base config".to_string(), + }, + ConfigLayerSource::User { + file: AbsolutePathBuf::from_absolute_path(codex_home.join(CONFIG_TOML_FILE))?, + profile: None, + }, + ] + ); + + Ok(()) +} + #[tokio::test] async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result<()> { let tmp = tempdir()?; @@ -1706,18 +1725,15 @@ async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result< overrides.system_requirements_path = Some(system_requirements_path); overrides.ignore_managed_requirements = true; - let cloud_requirements = CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - ..Default::default() - })) - }); + let cloud_config_bundle = CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["never"]"#, + ); let mut config = ConfigBuilder::default() .codex_home(codex_home) .fallback_cwd(Some(cwd.to_path_buf())) .loader_overrides(overrides) - .cloud_requirements(cloud_requirements) + .cloud_config_bundle(cloud_config_bundle) .build() .await?; @@ -1747,36 +1763,36 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result tokio::fs::create_dir_all(&managed_dir).await?; let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; - let requirements = ConfigRequirementsToml { - hooks: Some(codex_config::ManagedHooksRequirementsToml { - managed_dir: Some(managed_dir.clone()), - windows_managed_dir: None, - hooks: codex_config::HookEventsToml { - pre_tool_use: vec![codex_config::MatcherGroup { - matcher: Some("^Bash$".to_string()), - hooks: vec![codex_config::HookHandlerConfig::Command { - command: format!("python3 {}/pre.py", managed_dir.display()), - command_windows: None, - timeout_sec: Some(10), - r#async: false, - status_message: Some("checking".to_string()), - }], - }], - ..Default::default() - }, - }), - ..ConfigRequirementsToml::default() - }; - let expected = requirements.clone(); - let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) }); + let requirements = format!( + r#" +[hooks] +managed_dir = '{}' + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = 'python3 {}/pre.py' +timeout = 10 +statusMessage = "checking" +"#, + managed_dir.display(), + managed_dir.display() + ); + let expected: ConfigRequirementsToml = toml::from_str(&requirements)?; + let cloud_config_bundle = + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements); let layers = load_config_layers_state( LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], - LoaderOverrides::default(), - cloud_requirements, + ConfigLoadOptions { + cloud_config_bundle, + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1788,12 +1804,94 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result .managed_hooks .as_ref() .map(|hooks| hooks.source.clone()), - Some(Some(RequirementSource::CloudRequirements)) + Some(Some(cloud_config_bundle_requirement_source())) ); Ok(()) } +#[tokio::test] +async fn load_config_layers_resolves_relative_bundle_requirements_paths_against_codex_home() +-> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + + let requirements = r#" +[permissions.filesystem] +deny_read = ["secrets/**"] +"#; + let cloud_config_bundle = + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements); + + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + ConfigLoadOptions { + loader_overrides: LoaderOverrides::without_managed_config_for_tests(), + cloud_config_bundle, + ..Default::default() + }, + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + let permissions = layers + .requirements_toml() + .permissions + .clone() + .expect("permissions requirements should load"); + let filesystem = permissions + .filesystem + .expect("filesystem requirements should load"); + + assert_eq!( + filesystem.deny_read, + Some(vec![ + FilesystemDenyReadPattern::from_input(&format!("{}/secrets/**", codex_home.display())) + .expect("bundle requirements path should resolve against codex_home") + ]) + ); + + Ok(()) +} + +#[tokio::test] +async fn strict_config_rejects_unknown_cloud_config_key() { + let tmp = tempdir().expect("tempdir"); + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home) + .await + .expect("create codex home"); + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path()).expect("cwd"); + + let err = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + ConfigLoadOptions { + loader_overrides: LoaderOverrides::without_managed_config_for_tests(), + strict_config: true, + cloud_config_bundle: CloudConfigBundleFixture::loader_with_enterprise_config( + "unknown_key = true", + ), + }, + &codex_config::NoopThreadConfigLoader, + ) + .await + .expect_err("strict config should reject unknown cloud config keys"); + + assert!( + err.to_string() + .contains("unknown configuration field `unknown_key`"), + "{err:?}" + ); +} + #[tokio::test] async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow::Result<()> { let tmp = tempdir()?; @@ -1801,23 +1899,24 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow:: tokio::fs::create_dir_all(&codex_home).await?; let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; - let requirements: ConfigRequirementsToml = toml::from_str( - r#" + let requirements = r#" allowed_sandbox_modes = ["read-only"] [[remote_sandbox_config]] hostname_patterns = ["*"] allowed_sandbox_modes = ["read-only", "workspace-write"] - "#, - )?; - let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) }); + "#; + let cloud_config_bundle = + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements); let layers = load_config_layers_state( LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], - LoaderOverrides::default(), - cloud_requirements, + ConfigLoadOptions { + cloud_config_bundle, + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1841,7 +1940,7 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow:: } #[tokio::test] -async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyhow::Result<()> { +async fn load_config_layers_fails_when_cloud_config_bundle_loader_fails() -> anyhow::Result<()> { let tmp = tempdir()?; let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; @@ -1852,21 +1951,23 @@ async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyh &codex_home, Some(cwd), &[] as &[(String, TomlValue)], - LoaderOverrides::default(), - CloudRequirementsLoader::new(async { - Err(CloudRequirementsLoadError::new( - codex_config::CloudRequirementsLoadErrorCode::RequestFailed, - /*status_code*/ None, - "cloud requirements failed", - )) - }), + ConfigLoadOptions { + cloud_config_bundle: CloudConfigBundleLoader::new(async { + Err(CloudConfigBundleLoadError::new( + codex_config::CloudConfigBundleLoadErrorCode::RequestFailed, + /*status_code*/ None, + "cloud config bundle failed", + )) + }), + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await - .expect_err("cloud requirements failure should fail closed"); + .expect_err("cloud config bundle failure should fail closed"); assert_eq!(err.kind(), std::io::ErrorKind::Other); - assert!(err.to_string().contains("cloud requirements failed")); + assert!(err.to_string().contains("cloud config bundle failed")); Ok(()) } @@ -1909,7 +2010,6 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1992,7 +2092,6 @@ async fn linked_worktree_project_layers_keep_worktree_config_but_use_root_repo_h Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2074,7 +2173,6 @@ async fn linked_worktree_project_layers_use_root_repo_hooks_without_worktree_con Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2145,7 +2243,6 @@ async fn nested_project_root_markers_do_not_redirect_regular_repo_hooks() -> std Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2333,7 +2430,6 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2374,7 +2470,6 @@ async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::R Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2440,7 +2535,6 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2516,7 +2610,6 @@ profile = "ignored" Some(cwd.clone()), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2563,7 +2656,6 @@ profile = "ignored" Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2652,7 +2744,6 @@ wire_api = "responses" Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2754,7 +2845,6 @@ async fn project_trust_does_not_match_configured_alias_for_canonical_cwd() -> st Some(AbsolutePathBuf::from_absolute_path(&project_root)?), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2920,7 +3010,6 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io:: Some(cwd.clone()), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2989,7 +3078,6 @@ async fn project_layer_without_config_toml_is_disabled_when_untrusted_or_unknown Some(cwd.clone()), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -3050,7 +3138,6 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io Some(cwd), &cli_overrides, LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -3096,7 +3183,6 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 65d20d94f0..795057963e 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -104,6 +104,7 @@ use rmcp::model::ElicitationCapability; use rmcp::model::FormElicitationCapability; use rmcp::model::UrlElicitationCapability; +use codex_config::test_support::CloudConfigBundleFixture; use std::collections::BTreeMap; use std::collections::HashMap; use std::path::Path; @@ -1389,15 +1390,14 @@ async fn experimental_network_requirements_enable_proxy_without_feature() -> std let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - network: Some(codex_config::NetworkRequirementsToml { - enabled: Some(true), - ..Default::default() - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[experimental_network] +enabled = true +"#, + ), + ) .build() .await?; @@ -1867,7 +1867,7 @@ async fn managed_unrestricted_permission_profile_still_enables_network_requireme enabled: Some(true), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )); let mut requirements_toml = config.config_layer_stack.requirements_toml().clone(); requirements_toml.network = Some(codex_config::NetworkRequirementsToml { @@ -3932,7 +3932,7 @@ fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() { http_mcp("https://example.com/mcp"), ), ]); - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let requirements = Sourced::new( BTreeMap::from([( "sample@test".to_string(), @@ -3982,7 +3982,7 @@ fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() { #[test] fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() { let mut servers = HashMap::from([("server-a".to_string(), stdio_mcp("cmd-a"))]); - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let requirements = Sourced::new( BTreeMap::from([( "other@test".to_string(), @@ -4385,7 +4385,7 @@ enabled = true } #[tokio::test] -async fn to_mcp_config_applies_plugin_mcp_cloud_requirements() -> anyhow::Result<()> { +async fn to_mcp_config_applies_plugin_mcp_cloud_config_bundle() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let plugin_root = codex_home .path() @@ -4422,27 +4422,16 @@ enabled = true "#, )?; - let requirements = codex_config::ConfigRequirementsToml { - plugins: Some(BTreeMap::from([( - "sample@test".to_string(), - codex_config::PluginRequirementsToml { - mcp_servers: Some(BTreeMap::from([( - "sample".to_string(), - McpServerRequirement { - identity: McpServerIdentity::Url { - url: "https://sample.example/mcp".to_string(), - }, - }, - )])), - }, - )])), - ..Default::default() - }; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[plugins."sample@test".mcp_servers.sample.identity] +url = "https://sample.example/mcp" +"#, + ), + ) .build() .await?; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); @@ -4463,7 +4452,10 @@ enabled = true Some(( false, Some(McpServerDisabledReason::Requirements { - source: RequirementSource::CloudRequirements, + source: RequirementSource::EnterpriseManaged { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + }, }) )) ); @@ -4504,15 +4496,15 @@ enabled = true "#, )?; - let requirements = codex_config::ConfigRequirementsToml { - mcp_servers: Some(BTreeMap::new()), - ..Default::default() - }; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[mcp_servers] +"#, + ), + ) .build() .await?; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); @@ -4526,7 +4518,10 @@ enabled = true Some(( false, Some(McpServerDisabledReason::Requirements { - source: RequirementSource::CloudRequirements, + source: RequirementSource::EnterpriseManaged { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + }, }) )) ); @@ -5144,7 +5139,6 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { Some(cwd), &Vec::new(), overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -5278,7 +5272,6 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { Some(cwd), &[("model".to_string(), TomlValue::String("cli".to_string()))], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -9000,12 +8993,11 @@ async fn requirements_disallowing_default_sandbox_falls_back_to_required_default let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await?; assert_eq!( @@ -9024,35 +9016,14 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s "#, )?; - let requirements = codex_config::ConfigRequirementsToml { - allowed_approval_policies: None, - allowed_approvals_reviewers: None, - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - guardian_policy_config: None, - }; - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await?; assert_eq!( @@ -9072,21 +9043,16 @@ sandbox = "unelevated" "#, )?; - let requirements = codex_config::ConfigRequirementsToml { - windows: Some(codex_config::WindowsRequirementsToml { - allowed_sandbox_implementations: Some(vec![ - codex_config::types::WindowsSandboxModeToml::Elevated, - ]), - }), - ..Default::default() - }; - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"[windows] +allowed_sandbox_implementations = ["elevated"] +"#, + ), + ) .build() .await?; @@ -9117,12 +9083,11 @@ sandbox_mode = "danger-full-access" let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await .expect_err("requirements-constrained yolo should require sandbox approval"); @@ -9152,12 +9117,11 @@ default_permissions = "dev" let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await .expect_err("requirements-constrained full-access profile should require sandbox approval"); @@ -9174,11 +9138,6 @@ default_permissions = "dev" async fn permission_profile_override_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> { let codex_home = TempDir::new()?; - let requirements = codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - }; - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) @@ -9186,9 +9145,11 @@ async fn permission_profile_override_falls_back_when_disallowed_by_requirements( permission_profile: Some(PermissionProfile::Disabled), ..Default::default() }) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await?; @@ -9204,11 +9165,6 @@ async fn permission_profile_override_falls_back_when_disallowed_by_requirements( #[tokio::test] async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io::Result<()> { let codex_home = TempDir::new()?; - let requirements = codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - }; - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) @@ -9216,9 +9172,11 @@ async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()), ..Default::default() }) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await?; @@ -9328,14 +9286,11 @@ async fn requirements_web_search_mode_overrides_danger_full_access_default() -> let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_web_search_modes: Some(vec![ - codex_config::WebSearchModeRequirement::Cached, - ]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_web_search_modes = ["cached"]"#, + ), + ) .build() .await?; @@ -9369,12 +9324,11 @@ trust_level = "untrusted" let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(workspace.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["on-request"]"#, + ), + ) .build() .await?; @@ -9398,12 +9352,11 @@ async fn explicit_approval_policy_falls_back_when_disallowed_by_requirements() - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["on-request"]"#, + ), + ) .build() .await?; assert_eq!( @@ -9419,17 +9372,15 @@ async fn feature_requirements_normalize_effective_feature_values() -> std::io::R let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([ - ("personality".to_string(), true), - ("shell_tool".to_string(), false), - ]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +personality = true +shell_tool = false +"#, + ), + ) .build() .await?; @@ -9453,14 +9404,14 @@ async fn feature_requirements_auto_review_disables_guardian_approval() -> std::i let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([("auto_review".to_string(), false)]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +auto_review = false +"#, + ), + ) .build() .await?; @@ -9475,17 +9426,15 @@ async fn browser_feature_requirements_are_valid() -> std::io::Result<()> { let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([ - ("in_app_browser".to_string(), false), - ("browser_use".to_string(), false), - ]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +in_app_browser = false +browser_use = false +"#, + ), + ) .build() .await?; @@ -9581,17 +9530,15 @@ shell_tool = true let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([ - ("personality".to_string(), true), - ("shell_tool".to_string(), false), - ]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +personality = true +shell_tool = false +"#, + ), + ) .build() .await?; @@ -9701,12 +9648,11 @@ async fn requirements_disallowing_default_approvals_reviewer_falls_back_to_requi let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["guardian_subagent"]"#, + ), + ) .build() .await?; @@ -9727,12 +9673,11 @@ async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() -> let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["guardian_subagent"]"#, + ), + ) .build() .await?; @@ -9748,6 +9693,37 @@ async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() -> Ok(()) } +#[tokio::test] +async fn profile_approvals_reviewer_falls_back_when_disallowed_by_requirements() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let selected_config = codex_home.path().join("default.config.toml"); + std::fs::write( + &selected_config, + r#"approvals_reviewer = "user" +"#, + )?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(LoaderOverrides { + user_config_path: Some(selected_config.abs()), + user_config_profile: Some("default".parse().expect("profile-v2 name")), + ..LoaderOverrides::without_managed_config_for_tests() + }) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["guardian_subagent"]"#, + ), + ) + .build() + .await?; + + assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); + Ok(()) +} + #[tokio::test] async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirements() -> std::io::Result<()> { @@ -9761,15 +9737,11 @@ async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirem let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ - ApprovalsReviewer::User, - ApprovalsReviewer::AutoReview, - ]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["user", "guardian_subagent"]"#, + ), + ) .build() .await?; @@ -10255,17 +10227,15 @@ async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io:: let mut config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([ - ("personality".to_string(), true), - ("shell_tool".to_string(), false), - ]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +personality = true +shell_tool = false +"#, + ), + ) .build() .await?; @@ -10291,14 +10261,14 @@ async fn feature_requirements_warn_on_collab_legacy_alias() -> std::io::Result<( let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([("collab".to_string(), true)]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +collab = true +"#, + ), + ) .build() .await?; @@ -10321,14 +10291,14 @@ async fn feature_requirements_warn_and_ignore_unknown_feature() -> std::io::Resu let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([("made_up_feature".to_string(), true)]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +made_up_feature = true +"#, + ), + ) .build() .await?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 051064f279..9506819d03 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -7,7 +7,7 @@ use crate::unified_exec::MIN_EMPTY_YIELD_TIME_MS; use crate::windows_sandbox::WindowsSandboxLevelExt; use crate::windows_sandbox::resolve_windows_sandbox_mode; use crate::windows_sandbox::resolve_windows_sandbox_private_desktop; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; @@ -1112,7 +1112,7 @@ pub struct ConfigBuilder { harness_overrides: Option, loader_overrides: Option, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, thread_config_loader: Option>, fallback_cwd: Option, } @@ -1143,8 +1143,8 @@ impl ConfigBuilder { self } - pub fn cloud_requirements(mut self, cloud_requirements: CloudRequirementsLoader) -> Self { - self.cloud_requirements = cloud_requirements; + pub fn cloud_config_bundle(mut self, cloud_config_bundle: CloudConfigBundleLoader) -> Self { + self.cloud_config_bundle = cloud_config_bundle; self } @@ -1173,7 +1173,7 @@ impl ConfigBuilder { harness_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, thread_config_loader, fallback_cwd, } = self; @@ -1198,8 +1198,8 @@ impl ConfigBuilder { ConfigLoadOptions { loader_overrides, strict_config, + cloud_config_bundle, }, - cloud_requirements, thread_config_loader .as_deref() .unwrap_or(&codex_config::NoopThreadConfigLoader), @@ -1623,7 +1623,6 @@ pub async fn load_config_as_toml_with_cli_and_load_options( cwd.cloned(), &cli_overrides, options, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1834,7 +1833,6 @@ pub async fn load_global_mcp_servers( cwd, &cli_overrides, LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index c01fd5467a..0427ce5a4a 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -5,10 +5,10 @@ use codex_config::AppRequirementToml; use codex_config::AppToolRequirementToml; use codex_config::AppToolsRequirementsToml; use codex_config::AppsRequirementsToml; -use codex_config::CloudRequirementsLoader; use codex_config::ConfigLayerStack; use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; +use codex_config::test_support::CloudConfigBundleFixture; use codex_config::types::AppConfig; use codex_config::types::AppToolConfig; use codex_config::types::AppToolsConfig; @@ -455,15 +455,13 @@ approvals_reviewer = "user" "#, ) .expect("write config"); - let requirements = ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), - ..Default::default() - }; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["auto_review"]"#, + ), + ) .build() .await .expect("config should build"); @@ -541,7 +539,7 @@ fn requirements_enabled_does_not_override_disabled_connector() { } #[tokio::test] -async fn cloud_requirements_disable_connector_overrides_user_apps_config() { +async fn cloud_config_bundle_disable_connector_overrides_user_apps_config() { let codex_home = tempdir().expect("tempdir should succeed"); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -552,25 +550,17 @@ enabled = true ) .expect("write config"); - let requirements = ConfigRequirementsToml { - apps: Some(AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_123123".to_string(), - AppRequirementToml { - enabled: Some(false), - tools: None, - }, - )]), - }), - ..Default::default() - }; - let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[apps.connector_123123] +enabled = false +"#, + ), + ) .build() .await .expect("config should build"); @@ -592,29 +582,21 @@ enabled = true } #[tokio::test] -async fn cloud_requirements_disable_connector_applies_without_user_apps_table() { +async fn cloud_config_bundle_disable_connector_applies_without_user_apps_table() { let codex_home = tempdir().expect("tempdir should succeed"); std::fs::write(codex_home.path().join(CONFIG_TOML_FILE), "").expect("write config"); - let requirements = ConfigRequirementsToml { - apps: Some(AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_123123".to_string(), - AppRequirementToml { - enabled: Some(false), - tools: None, - }, - )]), - }), - ..Default::default() - }; - let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[apps.connector_123123] +enabled = false +"#, + ), + ) .build() .await .expect("config should build"); @@ -870,7 +852,7 @@ fn managed_app_tool_approval_uses_raw_tool_name() { } #[tokio::test] -async fn cloud_requirements_tool_approval_overrides_user_apps_config() { +async fn cloud_config_bundle_tool_approval_overrides_user_apps_config() { let codex_home = tempdir().expect("tempdir should succeed"); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -881,21 +863,17 @@ approval_mode = "prompt" ) .expect("write config"); - let requirements = ConfigRequirementsToml { - apps: Some(app_tool_requirements( - "connector_123123", - "calendar/list_events", - AppToolApproval::Approve, - )), - ..Default::default() - }; - let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[apps.connector_123123.tools."calendar/list_events"] +approval_mode = "approve" +"#, + ), + ) .build() .await .expect("config should build"); diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index a2d5fdaa19..b91eaa26ca 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -10,7 +10,6 @@ use anyhow::Result; use async_trait::async_trait; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; -use codex_config::CloudRequirementsLoader; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; use codex_config::LoaderOverrides; @@ -60,7 +59,6 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec an enabled: Some(true), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )); let mut requirements_toml = config.config_layer_stack.requirements_toml().clone(); requirements_toml.network = Some(codex_config::NetworkRequirementsToml { @@ -7101,7 +7101,7 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( }), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )); let layers = config .config_layer_stack diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 3d7fd21228..be07b803f8 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -8,10 +8,9 @@ use ctor::ctor; use std::sync::OnceLock; use tempfile::TempDir; -use codex_config::CloudRequirementsLoader; -use codex_config::ConfigRequirementsToml; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; -use codex_config::NetworkRequirementsToml; +use codex_config::test_support::CloudConfigBundleFixture; use codex_core::CodexThread; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -169,40 +168,37 @@ pub fn fetch_dotslash_file( /// temporary directory. Using a per-test directory keeps tests hermetic and /// avoids clobbering a developer’s real `~/.codex`. pub async fn load_default_config_for_test(codex_home: &TempDir) -> Config { - load_default_config_for_test_with_cloud_requirements( + load_default_config_for_test_with_cloud_config_bundle( codex_home, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ) .await } -/// Returns a default `Config` with test-provided cloud requirements applied +/// Returns a default `Config` with test-provided cloud bundle requirements applied. /// during config construction. -pub async fn load_default_config_for_test_with_cloud_requirements( +pub async fn load_default_config_for_test_with_cloud_config_bundle( codex_home: &TempDir, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, ) -> Config { ConfigBuilder::default() .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) .codex_home(codex_home.path().to_path_buf()) .harness_overrides(default_test_overrides()) - .cloud_requirements(cloud_requirements) + .cloud_config_bundle(cloud_config_bundle) .build() .await .expect("defaults for test should always succeed") } -pub fn managed_network_requirements_loader() -> CloudRequirementsLoader { - CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - network: Some(NetworkRequirementsToml { - enabled: Some(true), - allow_local_binding: Some(true), - ..Default::default() - }), - ..Default::default() - })) - }) +pub fn managed_network_requirements_loader() -> CloudConfigBundleLoader { + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[experimental_network] +enabled = true +allow_local_binding = true +"#, + ) } #[cfg(target_os = "linux")] diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 73e9e0ece8..4fe0609a71 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -12,7 +12,7 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_core::CodexThread; use codex_core::ThreadManager; use codex_core::config::Config; @@ -49,7 +49,7 @@ use crate::PathBufExt; use crate::TempDirExt; use crate::get_remote_test_env; use crate::load_default_config_for_test; -use crate::load_default_config_for_test_with_cloud_requirements; +use crate::load_default_config_for_test_with_cloud_config_bundle; use crate::responses::WebSocketTestServer; use crate::responses::output_value_to_text; use crate::responses::start_mock_server; @@ -213,7 +213,7 @@ pub struct TestCodexBuilder { pre_build_hooks: Vec>, workspace_setups: Vec>, home: Option>, - cloud_requirements: Option, + cloud_config_bundle: Option, user_shell_override: Option, exec_server_url: Option, } @@ -262,8 +262,11 @@ impl TestCodexBuilder { self } - pub fn with_cloud_requirements(mut self, cloud_requirements: CloudRequirementsLoader) -> Self { - self.cloud_requirements = Some(cloud_requirements); + pub fn with_cloud_config_bundle( + mut self, + cloud_config_bundle: CloudConfigBundleLoader, + ) -> Self { + self.cloud_config_bundle = Some(cloud_config_bundle); self } @@ -545,8 +548,8 @@ impl TestCodexBuilder { for hook in self.pre_build_hooks.drain(..) { hook(home.path()); } - let mut config = if let Some(cloud_requirements) = self.cloud_requirements.take() { - load_default_config_for_test_with_cloud_requirements(home, cloud_requirements).await + let mut config = if let Some(cloud_config_bundle) = self.cloud_config_bundle.take() { + load_default_config_for_test_with_cloud_config_bundle(home, cloud_config_bundle).await } else { load_default_config_for_test(home).await }; @@ -1031,7 +1034,7 @@ pub fn test_codex() -> TestCodexBuilder { pre_build_hooks: vec![], workspace_setups: vec![], home: None, - cloud_requirements: None, + cloud_config_bundle: None, user_shell_override: None, exec_server_url: None, } diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index a91196a0b9..aceac7320e 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -2999,7 +2999,7 @@ allow_local_binding = true let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex() .with_home(home) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config @@ -3279,7 +3279,7 @@ allow_local_binding = true }; let mut builder = test_codex() .with_home(home) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); }); @@ -3484,7 +3484,7 @@ allow_local_binding = true }; let mut builder = test_codex() .with_home(home) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); let cwd = config.cwd.clone(); diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index a535eb4b8e..5813508e7a 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -2229,7 +2229,7 @@ allow_local_binding = true panic!("failed to write permission request hook test fixture: {error}"); } }) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { trust_discovered_hooks(config); config.permissions.approval_policy = Constrained::allow_any(approval_policy); diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index ba754b025f..d86a0f620e 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -916,7 +916,7 @@ allow_local_binding = true let permission_profile = permission_profile_for_config.clone(); let mut builder = test_codex() .with_home(home) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.use_experimental_unified_exec_tool = true; config diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index e857f358c9..406197d720 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -51,7 +51,8 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartedNotification; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::cloud_requirements_loader_for_storage; +use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadError; use codex_config::ConfigLoadOptions; use codex_config::LoaderOverrides; @@ -330,53 +331,54 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result ..Default::default() }; - let config_toml = match load_config_as_toml_with_cli_and_load_options( + let bootstrap_config_toml = load_config_toml_or_exit( &codex_home, Some(&config_cwd), cli_kv_overrides.clone(), - ConfigLoadOptions { - loader_overrides: loader_overrides.clone(), - strict_config, - }, + loader_overrides.clone(), + strict_config, + CloudConfigBundleLoader::default(), ) - .await - { - Ok(config_toml) => config_toml, - Err(err) => { - let config_error = err - .get_ref() - .and_then(|err| err.downcast_ref::()) - .map(ConfigLoadError::config_error); - if let Some(config_error) = config_error { - eprintln!( - "Error loading config.toml:\n{}", - format_config_error_with_source(config_error) - ); - } else { - eprintln!("Error loading config.toml: {err}"); - } - std::process::exit(1); - } - }; + .await; - let chatgpt_base_url = config_toml + let chatgpt_base_url = bootstrap_config_toml .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - // TODO(gt): Make cloud requirements failures blocking once we can fail-closed. - let cloud_requirements = cloud_requirements_loader_for_storage( + let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, - config_toml.cli_auth_credentials_store.unwrap_or_default(), + bootstrap_config_toml + .cli_auth_credentials_store + .unwrap_or_default(), chatgpt_base_url, ) .await; let run_cli_overrides = cli_kv_overrides.clone(); let run_loader_overrides = loader_overrides.clone(); - let run_cloud_requirements = cloud_requirements.clone(); + let run_cloud_config_bundle = cloud_config_bundle.clone(); let model_provider = if oss { - let resolved = resolve_oss_provider(oss_provider.as_deref(), &config_toml); + let config_toml_with_cloud_config; + let config_toml_for_oss = if oss_provider.is_none() { + // The first load intentionally skips cloud config so we can read + // auth/base-url settings needed to fetch the bundle. If OSS mode + // needs a default provider from config, reload with the bundle. + config_toml_with_cloud_config = load_config_toml_or_exit( + &codex_home, + Some(&config_cwd), + cli_kv_overrides.clone(), + loader_overrides.clone(), + strict_config, + cloud_config_bundle.clone(), + ) + .await; + &config_toml_with_cloud_config + } else { + &bootstrap_config_toml + }; + + let resolved = resolve_oss_provider(oss_provider.as_deref(), config_toml_for_oss); if let Some(provider) = resolved { Some(provider) @@ -437,7 +439,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .harness_overrides(overrides) .loader_overrides(loader_overrides.clone()) .strict_config(strict_config) - .cloud_requirements(cloud_requirements.clone()) + .cloud_config_bundle(cloud_config_bundle.clone()) .build() }; let config = build_exec_config( @@ -536,7 +538,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result cli_overrides: run_cli_overrides, loader_overrides: run_loader_overrides, strict_config, - cloud_requirements: run_cloud_requirements, + cloud_config_bundle: run_cloud_config_bundle, feedback: CodexFeedback::new(), log_db: None, state_db: state_db.clone(), @@ -606,6 +608,46 @@ where } } +#[allow(clippy::print_stderr)] +async fn load_config_toml_or_exit( + codex_home: &Path, + cwd: Option<&AbsolutePathBuf>, + cli_kv_overrides: Vec<(String, codex_config::TomlValue)>, + loader_overrides: LoaderOverrides, + strict_config: bool, + cloud_config_bundle: CloudConfigBundleLoader, +) -> codex_config::config_toml::ConfigToml { + match load_config_as_toml_with_cli_and_load_options( + codex_home, + cwd, + cli_kv_overrides, + ConfigLoadOptions { + loader_overrides, + strict_config, + cloud_config_bundle, + }, + ) + .await + { + Ok(config_toml) => config_toml, + Err(err) => { + let config_error = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .map(ConfigLoadError::config_error); + if let Some(config_error) = config_error { + eprintln!( + "Error loading config.toml:\n{}", + format_config_error_with_source(config_error) + ); + } else { + eprintln!("Error loading config.toml: {err}"); + } + std::process::exit(1); + } + } +} + async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let ExecRunArgs { in_process_start_args, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index d9bebb1b32..8b2f214987 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -578,6 +578,7 @@ async fn thread_lifecycle_params_include_legacy_sandbox_when_no_active_profile() let codex_home = tempdir().expect("create temp codex home"); let cwd = tempdir().expect("create temp cwd"); let config = ConfigBuilder::default() + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { sandbox_mode: Some(SandboxMode::DangerFullAccess), diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index fd13753ed5..92d17fbabd 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -274,9 +274,6 @@ fn fallback_managed_hooks_source_path( Some(RequirementSource::MdmManagedPreferences { domain, key }) => { synthetic_layer_path(&format!("/requirements.toml")) } - Some(RequirementSource::CloudRequirements) => { - synthetic_layer_path("/requirements.toml") - } Some(RequirementSource::Composite { .. }) => { synthetic_layer_path("/requirements.toml") } @@ -635,7 +632,6 @@ fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> Hoo Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => { HookSource::LegacyManagedConfigMdm } - Some(RequirementSource::CloudRequirements) => HookSource::CloudRequirements, Some(RequirementSource::Composite { sources }) => { // Requirements hook composition preserves contributing sources in // priority order, but discovery only carries one source for the diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 9b642503a0..4b7697b34f 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -120,12 +120,12 @@ fn requirements_with_managed_hooks_only( ConfigRequirements { allow_managed_hooks_only: Some(Sourced::new( allow_managed_hooks_only, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), managed_hooks: managed_hooks.clone().map(|hooks| { ConstrainedWithSource::new( Constrained::allow_any(hooks), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ) }), ..ConfigRequirements::default() @@ -183,7 +183,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, @@ -208,7 +208,10 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: assert!(engine.warnings().is_empty()); assert_eq!(engine.handlers.len(), 1); - assert_eq!(engine.handlers[0].source, HookSource::CloudRequirements); + assert_eq!( + engine.handlers[0].source, + HookSource::LegacyManagedConfigMdm + ); let listed = crate::list_hooks(crate::HooksConfig { legacy_notify_argv: None, feature_enabled: true, @@ -286,7 +289,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() { ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, @@ -447,7 +450,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, @@ -471,7 +474,10 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { ); assert_eq!(engine.handlers.len(), 1); - assert_eq!(engine.handlers[0].source, HookSource::CloudRequirements); + assert_eq!( + engine.handlers[0].source, + HookSource::LegacyManagedConfigMdm + ); let discovered = super::discovery::discover_handlers( Some(&config_layer_stack), Vec::new(), @@ -671,7 +677,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() { ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, @@ -1237,7 +1243,12 @@ print(json.dumps({ assert_eq!(outcome.hook_events.len(), 1); assert_eq!(outcome.hook_events[0].run.source, HookSource::Plugin); - assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Completed); + assert_eq!( + outcome.hook_events[0].run.status, + HookRunStatus::Completed, + "hook entries: {:#?}", + outcome.hook_events[0].run.entries + ); assert_eq!(outcome.hook_events[0].run.entries.len(), 1); assert_eq!( outcome.hook_events[0].run.entries[0].kind, diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index c9d91eed55..562df95989 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -628,7 +628,7 @@ mod tests { let requirements = ConfigRequirements { approval_policy: ConstrainedWithSource::new( Constrained::allow_any(AskForApproval::OnRequest.to_core()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ), approvals_reviewer: ConstrainedWithSource::new( Constrained::allow_any(ApprovalsReviewer::AutoReview), @@ -653,25 +653,25 @@ mod tests { )), enforce_residency: ConstrainedWithSource::new( Constrained::allow_any(Some(ResidencyRequirement::Us)), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ), web_search_mode: ConstrainedWithSource::new( Constrained::allow_any(WebSearchMode::Cached), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ), allow_managed_hooks_only: Some(Sourced::new( /*value*/ true, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), allow_appshots: Some(Sourced::new( /*value*/ false, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), feature_requirements: Some(Sourced::new( FeatureRequirementsToml { entries: BTreeMap::from([("guardian_approval".to_string(), true)]), }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), network: Some(Sourced::new( NetworkConstraints { @@ -684,7 +684,7 @@ mod tests { }), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), filesystem: Some(Sourced::new( FilesystemConstraints { @@ -694,7 +694,7 @@ mod tests { file: requirements_file.clone(), }, )), - guardian_policy_config_source: Some(RequirementSource::CloudRequirements), + guardian_policy_config_source: Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ..ConfigRequirements::default() }; @@ -749,9 +749,10 @@ mod tests { .expect("config layer stack"); let rendered = render_to_text(&render_debug_config_lines(&stack)); - assert!( - rendered.contains("allowed_approval_policies: on-request (source: cloud requirements)") - ); + let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); + assert!(rendered.contains(&format!( + "allowed_approval_policies: on-request (source: {requirements_source})" + ))); assert!(rendered.contains( "allowed_approvals_reviewers: guardian_subagent (source: MDM managed_config.toml (legacy))" )); @@ -764,22 +765,28 @@ mod tests { .as_str(), ) ); - assert!( - rendered.contains( - "allowed_web_search_modes: cached, disabled (source: cloud requirements)" - ) - ); - assert!(rendered.contains("allow_managed_hooks_only: true (source: cloud requirements)")); - assert!(rendered.contains("allow_appshots: false (source: cloud requirements)")); - assert!( - rendered.contains("guardian_policy_config: configured (source: cloud requirements)") - ); - assert!(rendered.contains("features: guardian_approval=true (source: cloud requirements)")); + assert!(rendered.contains(&format!( + "allowed_web_search_modes: cached, disabled (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "allow_managed_hooks_only: true (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "allow_appshots: false (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "guardian_policy_config: configured (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "features: guardian_approval=true (source: {requirements_source})" + ))); assert!(rendered.contains("mcp_servers: docs (source: MDM managed_config.toml (legacy))")); - assert!(rendered.contains("enforce_residency: us (source: cloud requirements)")); - assert!(rendered.contains( - "experimental_network: enabled=true, domains={example.com=allow} (source: cloud requirements)" - )); + assert!(rendered.contains(&format!( + "enforce_residency: us (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "experimental_network: enabled=true, domains={{example.com=allow}} (source: {requirements_source})" + ))); assert!( rendered.contains( format!( @@ -834,7 +841,7 @@ mod tests { }), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), ..ConfigRequirements::default() }; @@ -844,9 +851,10 @@ mod tests { .expect("config layer stack"); let rendered = render_to_text(&render_debug_config_lines(&stack)); - assert!(rendered.contains( - "experimental_network: unix_sockets={/tmp/blocked.sock=deny, /tmp/codex.sock=allow} (source: cloud requirements)" - )); + let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); + assert!(rendered.contains(&format!( + "experimental_network: unix_sockets={{/tmp/blocked.sock=deny, /tmp/codex.sock=allow}} (source: {requirements_source})" + ))); } #[test] @@ -956,7 +964,7 @@ approval_policy = "never" let requirements = ConfigRequirements { web_search_mode: ConstrainedWithSource::new( Constrained::allow_any(WebSearchMode::Disabled), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ), ..ConfigRequirements::default() }; @@ -988,9 +996,10 @@ approval_policy = "never" .expect("config layer stack"); let rendered = render_to_text(&render_debug_config_lines(&stack)); - assert!( - rendered.contains("allowed_web_search_modes: disabled (source: cloud requirements)") - ); + let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); + assert!(rendered.contains(&format!( + "allowed_web_search_modes: disabled (source: {requirements_source})" + ))); } #[test] @@ -1018,7 +1027,7 @@ approval_policy = "never" ..Default::default() }, }), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }; @@ -1033,9 +1042,10 @@ approval_policy = "never" .expect("config layer stack"); let rendered = render_to_text(&render_debug_config_lines(&stack)); + let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); assert!(rendered.contains("hooks:")); assert!(rendered.contains("handlers=1")); - assert!(rendered.contains("(source: cloud requirements)")); + assert!(rendered.contains(&format!("(source: {requirements_source})"))); } #[test] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e9cc88dca1..b53ec0f83d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -38,8 +38,8 @@ use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; -use codex_cloud_config::cloud_requirements_loader_for_storage; -use codex_config::CloudRequirementsLoader; +use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadError; use codex_config::LoaderOverrides; use codex_config::format_config_error_with_source; @@ -298,7 +298,7 @@ async fn start_embedded_app_server( cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, @@ -310,7 +310,7 @@ async fn start_embedded_app_server( cli_kv_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, feedback, log_db, state_db, @@ -510,7 +510,7 @@ async fn start_app_server( cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, @@ -523,7 +523,7 @@ async fn start_app_server( cli_kv_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, feedback, log_db, state_db, @@ -550,7 +550,7 @@ pub(crate) async fn start_app_server_for_picker( Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, state_db, @@ -584,7 +584,7 @@ async fn start_embedded_app_server_with( cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, @@ -611,7 +611,7 @@ where cli_overrides: cli_kv_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, feedback, log_db, state_db, @@ -977,51 +977,58 @@ pub async fn run_main( loader_overrides.user_config_profile = Some(profile_v2.clone()); } - #[allow(clippy::print_stderr)] - let config_toml = match load_config_as_toml_with_cli_and_load_options( + let bootstrap_config_toml = load_config_toml_or_exit( &codex_home, config_cwd.as_ref(), cli_kv_overrides.clone(), - codex_config::ConfigLoadOptions { - loader_overrides: loader_overrides.clone(), - strict_config, - }, + loader_overrides.clone(), + strict_config, + CloudConfigBundleLoader::default(), ) - .await - { - Ok(config_toml) => config_toml, - Err(err) => { - let config_error = err - .get_ref() - .and_then(|err| err.downcast_ref::()) - .map(ConfigLoadError::config_error); - if let Some(config_error) = config_error { - eprintln!( - "Error loading config.toml:\n{}", - format_config_error_with_source(config_error) - ); - } else { - eprintln!("Error loading config.toml: {err}"); - } - std::process::exit(1); - } - }; + .await; - let chatgpt_base_url = config_toml + let chatgpt_base_url = bootstrap_config_toml .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - let cloud_requirements = cloud_requirements_loader_for_storage( + let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, - config_toml.cli_auth_credentials_store.unwrap_or_default(), + bootstrap_config_toml + .cli_auth_credentials_store + .unwrap_or_default(), chatgpt_base_url, ) .await; + let cwd_override = if app_server_target.uses_remote_workspace() { + None + } else { + cwd.clone() + }; + let mut manually_selected_oss_provider = None; let model_provider_override = if cli.oss { - let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), &config_toml); + let config_toml_with_cloud_config; + let config_toml_for_oss = if cli.oss_provider.is_none() { + // The first load intentionally skips cloud config so we can read + // auth/base-url settings needed to fetch the bundle. If OSS mode + // needs a default provider from config, reload with the bundle. + config_toml_with_cloud_config = load_config_toml_or_exit( + &codex_home, + config_cwd.as_ref(), + cli_kv_overrides.clone(), + loader_overrides.clone(), + strict_config, + cloud_config_bundle.clone(), + ) + .await; + &config_toml_with_cloud_config + } else { + &bootstrap_config_toml + }; + + let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), config_toml_for_oss); if let Some(provider) = resolved { Some(provider) @@ -1062,11 +1069,7 @@ pub async fn run_main( model, approval_policy, sandbox_mode, - cwd: if app_server_target.uses_remote_workspace() { - None - } else { - cwd - }, + cwd: cwd_override, model_provider: model_provider_override.clone(), codex_self_exe: arg0_paths.codex_self_exe.clone(), codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), @@ -1081,7 +1084,7 @@ pub async fn run_main( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, ) .await; @@ -1137,7 +1140,7 @@ pub async fn run_main( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, ) .await; @@ -1156,6 +1159,11 @@ pub async fn run_main( tracing::warn!(error = %err, "failed to deserialize config for personality migration"); } } + let config_toml_log_dir_configured = config + .config_layer_stack + .effective_config() + .as_table() + .is_some_and(|table| table.contains_key("log_dir")); #[allow(clippy::print_stderr)] match check_execpolicy_for_warnings(&config.config_layer_stack).await { @@ -1199,7 +1207,7 @@ pub async fn run_main( } } - let (tui_file_layer, _tui_file_log_guard) = if config_toml.log_dir.is_some() { + let (tui_file_layer, _tui_file_log_guard) = if config_toml_log_dir_configured { let log_dir = config.log_dir.clone(); std::fs::create_dir_all(&log_dir)?; let mut log_file_opts = OpenOptions::new(); @@ -1281,7 +1289,7 @@ pub async fn run_main( manually_selected_oss_provider, overrides, cli_kv_overrides, - cloud_requirements, + cloud_config_bundle, feedback, log_db, state_db, @@ -1303,7 +1311,7 @@ async fn run_ratatui_app( manually_selected_oss_provider: Option, overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, - mut cloud_requirements: CloudRequirementsLoader, + mut cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, @@ -1365,7 +1373,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), loader_overrides.clone(), strict_config, - cloud_requirements.clone(), + cloud_config_bundle.clone(), feedback.clone(), log_db.clone(), state_db.clone(), @@ -1448,11 +1456,11 @@ async fn run_ratatui_app( { trust_decision_was_made = onboarding_result.directory_trust_persisted; } - // If this onboarding run included the login step, always refresh cloud requirements and - // rebuild config. This avoids missing newly available cloud requirements due to login + // If this onboarding run included the login step, always refresh the cloud config bundle + // and rebuild config. This avoids missing newly available cloud-managed policy due to login // status detection edge cases. if show_login_screen && !uses_remote_workspace { - cloud_requirements = cloud_requirements_loader_for_storage( + cloud_config_bundle = cloud_config_bundle_loader_for_storage( initial_config.codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, initial_config.cli_auth_credentials_store_mode, @@ -1470,7 +1478,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, ) .await @@ -1674,7 +1682,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, fallback_cwd, ) @@ -1685,7 +1693,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, ) .await @@ -1745,7 +1753,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), loader_overrides.clone(), strict_config, - cloud_requirements.clone(), + cloud_config_bundle.clone(), feedback.clone(), log_db.clone(), state_db.clone(), @@ -1902,14 +1910,14 @@ async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, loader_overrides: LoaderOverrides, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, strict_config: bool, ) -> Config { load_config_or_exit_with_fallback_cwd( cli_kv_overrides, overrides, loader_overrides, - cloud_requirements, + cloud_config_bundle, strict_config, /*fallback_cwd*/ None, ) @@ -1920,7 +1928,7 @@ async fn load_config_or_exit_with_fallback_cwd( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, loader_overrides: LoaderOverrides, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, strict_config: bool, fallback_cwd: Option, ) -> Config { @@ -1930,7 +1938,7 @@ async fn load_config_or_exit_with_fallback_cwd( .harness_overrides(overrides) .loader_overrides(loader_overrides) .strict_config(strict_config) - .cloud_requirements(cloud_requirements) + .cloud_config_bundle(cloud_config_bundle) .fallback_cwd(fallback_cwd) .build() .await @@ -1943,6 +1951,46 @@ async fn load_config_or_exit_with_fallback_cwd( } } +#[allow(clippy::print_stderr)] +async fn load_config_toml_or_exit( + codex_home: &Path, + cwd: Option<&AbsolutePathBuf>, + cli_kv_overrides: Vec<(String, codex_config::TomlValue)>, + loader_overrides: LoaderOverrides, + strict_config: bool, + cloud_config_bundle: CloudConfigBundleLoader, +) -> codex_config::config_toml::ConfigToml { + match load_config_as_toml_with_cli_and_load_options( + codex_home, + cwd, + cli_kv_overrides, + codex_config::ConfigLoadOptions { + loader_overrides, + strict_config, + cloud_config_bundle, + }, + ) + .await + { + Ok(config_toml) => config_toml, + Err(err) => { + let config_error = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .map(ConfigLoadError::config_error); + if let Some(config_error) = config_error { + eprintln!( + "Error loading config.toml:\n{}", + format_config_error_with_source(config_error) + ); + } else { + eprintln!("Error loading config.toml: {err}"); + } + std::process::exit(1); + } + } +} + /// Determine if the user has decided whether to trust the current directory. fn should_show_trust_screen(config: &Config) -> bool { config.active_project.trust_level.is_none() @@ -2017,7 +2065,7 @@ mod tests { Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, state_db, @@ -2771,7 +2819,7 @@ mod tests { Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, /*state_db*/ None, diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index fa3561a7fa..0d8a1fa773 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1012,7 +1012,7 @@ mod tests { use codex_app_server_client::InProcessAppServerClient; use codex_app_server_client::InProcessClientStartArgs; use codex_arg0::Arg0DispatchPaths; - use codex_cloud_config::cloud_requirements_loader_for_storage; + use codex_cloud_config::cloud_config_bundle_loader_for_storage; use codex_config::types::AuthCredentialsStoreMode; use pretty_assertions::assert_eq; @@ -1033,7 +1033,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: Default::default(), strict_config: false, - cloud_requirements: cloud_requirements_loader_for_storage( + cloud_config_bundle: cloud_config_bundle_loader_for_storage( codex_home_path.clone(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 40d33eb2f6..4e5c420f63 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -16,7 +16,8 @@ use codex_app_server_protocol::Thread as AppServerThread; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::cloud_requirements_loader_for_storage; +use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadOptions; use codex_config::LoaderOverrides; use codex_exec_server::EnvironmentManager; @@ -251,6 +252,7 @@ async fn start_app_server_for_archive_command( ConfigLoadOptions { loader_overrides: loader_overrides.clone(), strict_config, + cloud_config_bundle: CloudConfigBundleLoader::default(), }, ) .await @@ -259,7 +261,7 @@ async fn start_app_server_for_archive_command( .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - let cloud_requirements = cloud_requirements_loader_for_storage( + let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, config_toml.cli_auth_credentials_store.unwrap_or_default(), @@ -298,7 +300,7 @@ async fn start_app_server_for_archive_command( }) .loader_overrides(loader_overrides.clone()) .strict_config(strict_config) - .cloud_requirements(cloud_requirements.clone()) + .cloud_config_bundle(cloud_config_bundle.clone()) .build() .await .wrap_err("failed to load configuration")?; @@ -312,7 +314,7 @@ async fn start_app_server_for_archive_command( cli_kv_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, codex_feedback::CodexFeedback::new(), /*log_db*/ None, state_db, From c74be11672cdf90cda60869961075965b02f213a Mon Sep 17 00:00:00 2001 From: joeflorencio-openai Date: Tue, 2 Jun 2026 14:11:20 -0700 Subject: [PATCH 15/19] fix: update image generation test helper rename (#25938) ## Summary - update the app-server image generation integration test to use `TestAppServer` - completes the test helper rename from #25701 for this newer test file ## Validation - `cargo fmt -- --config imports_granularity=Item` - `cargo check -p codex-app-server --test all` Note: `just fmt` ran Rust formatting but failed on Python/SDK formatting because the sandbox could not access the local `uv` cache. --- codex-rs/app-server/tests/suite/v2/image_generation.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/image_generation.rs b/codex-rs/app-server/tests/suite/v2/image_generation.rs index 4752f1497e..e1f4cfbc9a 100644 --- a/codex-rs/app-server/tests/suite/v2/image_generation.rs +++ b/codex-rs/app-server/tests/suite/v2/image_generation.rs @@ -4,7 +4,7 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::ItemCompletedNotification; @@ -76,7 +76,8 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -157,7 +158,7 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> } async fn wait_for_image_generation_completed( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, ) -> Result { loop { let notification = mcp From f6d64bd6ab3bf1a9587bbc13a00711029120d3e9 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 2 Jun 2026 14:16:12 -0700 Subject: [PATCH 16/19] core: stop passing legacy SandboxPolicy to guardian reviews (#25911) ## Why Guardian review turns already submit a read-only `PermissionProfile`, which is the permissions model the runtime should honor. Passing the equivalent legacy `SandboxPolicy` through `ThreadSettingsOverrides` keeps two representations of the same read-only constraint alive on this path and makes the guardian flow depend on compatibility plumbing that is being phased out. ## What Changed - Set `sandbox_policy` to `None` when the guardian review session submits its child `Op::UserInput`. - Keep `permission_profile: Some(PermissionProfile::read_only())` and `approval_policy: Some(AskForApproval::Never)`, so the guardian review remains read-only and cannot request approvals. - Remove the now-unused `SandboxPolicy` import and redundant comment from `codex-rs/core/src/guardian/review_session.rs`. ## Verification Not run locally; this is a narrow cleanup of redundant thread-settings override state. --- codex-rs/core/src/guardian/review_session.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index fed87cd75f..b8c5c98075 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -20,7 +20,6 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; @@ -719,9 +718,7 @@ async fn run_review_on_session( .total_token_usage() .await .unwrap_or_default(); - // The legacy SandboxPolicy should match the PermissionProfile. let guardian_permission_profile = PermissionProfile::read_only(); - let legacy_sandbox_policy = SandboxPolicy::new_read_only_policy(); let submit_result = run_before_review_deadline( deadline, @@ -736,7 +733,7 @@ async fn run_review_on_session( #[allow(deprecated)] cwd: Some(params.parent_turn.cwd.to_path_buf()), approval_policy: Some(AskForApproval::Never), - sandbox_policy: Some(legacy_sandbox_policy), + sandbox_policy: None, permission_profile: Some(guardian_permission_profile), summary: Some(params.reasoning_summary), personality: params.personality, From e7039f98449d0e39687aab14eda4b4383b3bce31 Mon Sep 17 00:00:00 2001 From: joeflorencio-openai Date: Tue, 2 Jun 2026 14:30:12 -0700 Subject: [PATCH 17/19] Split cloud config bundle service modules (#25668) ## Summary - Splits the monolithic `codex-cloud-config` implementation into focused modules. - Keeps behavior unchanged from the preceding config bundle runtime switch. ## Details This is the reviewability follow-up after the lineage-preserving migration PRs. The split separates backend transport, loader construction, cache handling, metrics, validation, service orchestration, and focused tests into named files. Verification: `just fmt`; `just test -p codex-cloud-config`. --- codex-rs/Cargo.lock | 1 - codex-rs/cloud-config/Cargo.toml | 1 - codex-rs/cloud-config/src/backend.rs | 136 ++ codex-rs/cloud-config/src/bundle_loader.rs | 68 + codex-rs/cloud-config/src/cache.rs | 253 +++ codex-rs/cloud-config/src/cache_tests.rs | 206 ++ codex-rs/cloud-config/src/lib.rs | 2226 +------------------- codex-rs/cloud-config/src/metrics.rs | 95 + codex-rs/cloud-config/src/service.rs | 500 +++++ codex-rs/cloud-config/src/service_tests.rs | 1035 +++++++++ codex-rs/cloud-config/src/validation.rs | 34 + 11 files changed, 2336 insertions(+), 2219 deletions(-) create mode 100644 codex-rs/cloud-config/src/backend.rs create mode 100644 codex-rs/cloud-config/src/bundle_loader.rs create mode 100644 codex-rs/cloud-config/src/cache.rs create mode 100644 codex-rs/cloud-config/src/cache_tests.rs create mode 100644 codex-rs/cloud-config/src/metrics.rs create mode 100644 codex-rs/cloud-config/src/service.rs create mode 100644 codex-rs/cloud-config/src/service_tests.rs create mode 100644 codex-rs/cloud-config/src/validation.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e7bb18b5f6..33392db931 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2347,7 +2347,6 @@ dependencies = [ name = "codex-cloud-config" version = "0.0.0" dependencies = [ - "async-trait", "base64 0.22.1", "chrono", "codex-backend-client", diff --git a/codex-rs/cloud-config/Cargo.toml b/codex-rs/cloud-config/Cargo.toml index b9afaee105..6bf58c8399 100644 --- a/codex-rs/cloud-config/Cargo.toml +++ b/codex-rs/cloud-config/Cargo.toml @@ -8,7 +8,6 @@ license.workspace = true workspace = true [dependencies] -async-trait = { workspace = true } base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } codex-backend-client = { workspace = true } diff --git a/codex-rs/cloud-config/src/backend.rs b/codex-rs/cloud-config/src/backend.rs new file mode 100644 index 0000000000..cb99316a70 --- /dev/null +++ b/codex-rs/cloud-config/src/backend.rs @@ -0,0 +1,136 @@ +use codex_backend_client::Client as BackendClient; +use codex_backend_client::ConfigBundleResponse; +use codex_backend_client::DeliveredTomlFragment; +use codex_config::CloudConfigBundle; +use codex_config::CloudConfigFragment; +use codex_config::CloudConfigTomlBundle; +use codex_config::CloudRequirementsFragment; +use codex_config::CloudRequirementsTomlBundle; +use codex_login::CodexAuth; +use std::future::Future; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RetryableFailureKind { + BackendClientInit, + Request { status_code: Option }, +} + +impl RetryableFailureKind { + pub(crate) fn status_code(self) -> Option { + match self { + Self::BackendClientInit => None, + Self::Request { status_code } => status_code, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum BundleRequestError { + Retryable(RetryableFailureKind), + Unauthorized { + status_code: Option, + message: String, + }, +} + +/// Retrieves one cloud config bundle from the backend. +/// +/// Implementations should return the backend-selected bundle exactly as delivered and leave +/// validation, caching, and config/requirements parsing decisions to the service layer. +pub(crate) trait BundleClient: Send + Sync { + fn get_bundle( + &self, + auth: &CodexAuth, + ) -> impl Future> + Send; +} + +pub(crate) struct BackendBundleClient { + base_url: String, +} + +impl BackendBundleClient { + pub(crate) fn new(base_url: String) -> Self { + Self { base_url } + } +} + +impl BundleClient for BackendBundleClient { + async fn get_bundle(&self, auth: &CodexAuth) -> Result { + let client = BackendClient::from_auth(self.base_url.clone(), auth) + .inspect_err(|err| { + tracing::warn!( + error = %err, + "Failed to construct backend client for cloud config bundle" + ); + }) + .map_err(|_| BundleRequestError::Retryable(RetryableFailureKind::BackendClientInit))?; + + let response = client + .get_config_bundle() + .await + .inspect_err(|err| { + tracing::warn!(error = %err, "Failed to fetch cloud config bundle"); + }) + .map_err(|err| { + let status_code = err.status().map(|status| status.as_u16()); + if err.is_unauthorized() { + BundleRequestError::Unauthorized { + status_code, + message: err.to_string(), + } + } else { + BundleRequestError::Retryable(RetryableFailureKind::Request { status_code }) + } + })?; + + Ok(bundle_from_response(response)) + } +} + +pub(crate) fn bundle_from_response(response: ConfigBundleResponse) -> CloudConfigBundle { + let config_toml = response + .config_toml + .flatten() + .map(|config_toml| *config_toml) + .and_then(|config_toml| config_toml.enterprise_managed.flatten()) + .unwrap_or_default() + .into_iter() + .map(config_fragment_from_delivered) + .collect(); + let requirements_toml = response + .requirements_toml + .flatten() + .map(|requirements_toml| *requirements_toml) + .and_then(|requirements_toml| requirements_toml.enterprise_managed.flatten()) + .unwrap_or_default() + .into_iter() + .map(requirements_fragment_from_delivered) + .collect(); + + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: config_toml, + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: requirements_toml, + }, + } +} + +fn config_fragment_from_delivered(fragment: DeliveredTomlFragment) -> CloudConfigFragment { + CloudConfigFragment { + id: fragment.id, + name: fragment.name, + contents: fragment.contents, + } +} + +fn requirements_fragment_from_delivered( + fragment: DeliveredTomlFragment, +) -> CloudRequirementsFragment { + CloudRequirementsFragment { + id: fragment.id, + name: fragment.name, + contents: fragment.contents, + } +} diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs new file mode 100644 index 0000000000..c269460c1f --- /dev/null +++ b/codex-rs/cloud-config/src/bundle_loader.rs @@ -0,0 +1,68 @@ +use crate::backend::BackendBundleClient; +use crate::service::CLOUD_CONFIG_BUNDLE_TIMEOUT; +use crate::service::CloudConfigBundleService; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoadErrorCode; +use codex_config::CloudConfigBundleLoader; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthManager; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::OnceLock; +use tokio::task::JoinHandle; + +fn refresher_task_slot() -> &'static Mutex>> { + static REFRESHER_TASK: OnceLock>>> = OnceLock::new(); + REFRESHER_TASK.get_or_init(|| Mutex::new(None)) +} + +pub fn cloud_config_bundle_loader( + auth_manager: Arc, + chatgpt_base_url: String, + codex_home: PathBuf, +) -> CloudConfigBundleLoader { + let service = CloudConfigBundleService::new( + auth_manager, + Arc::new(BackendBundleClient::new(chatgpt_base_url)), + codex_home, + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + let refresh_service = service.clone(); + let task = tokio::spawn(async move { service.load_startup_bundle_with_timeout().await }); + let refresh_task = + tokio::spawn(async move { refresh_service.refresh_cache_in_background().await }); + let mut refresher_guard = refresher_task_slot().lock().unwrap_or_else(|err| { + tracing::warn!("cloud config bundle refresher task slot was poisoned"); + err.into_inner() + }); + if let Some(existing_task) = refresher_guard.replace(refresh_task) { + existing_task.abort(); + } + CloudConfigBundleLoader::new(async move { + task.await.map_err(|err| { + tracing::error!(error = %err, "Cloud config bundle task failed"); + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Internal, + /*status_code*/ None, + format!("cloud config bundle load failed: {err}"), + ) + })? + }) +} + +pub async fn cloud_config_bundle_loader_for_storage( + codex_home: PathBuf, + enable_codex_api_key_env: bool, + credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: String, +) -> CloudConfigBundleLoader { + let auth_manager = AuthManager::shared( + codex_home.clone(), + enable_codex_api_key_env, + credentials_store_mode, + Some(chatgpt_base_url.clone()), + ) + .await; + cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home) +} diff --git a/codex-rs/cloud-config/src/cache.rs b/codex-rs/cloud-config/src/cache.rs new file mode 100644 index 0000000000..1e68646159 --- /dev/null +++ b/codex-rs/cloud-config/src/cache.rs @@ -0,0 +1,253 @@ +//! Signed on-disk cache for cloud config bundles. +//! +//! The cache is scoped to the authenticated ChatGPT user and account, has a +//! short TTL, and is HMAC-signed so malformed or edited files fail closed. + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use chrono::DateTime; +use chrono::Duration as ChronoDuration; +use chrono::Utc; +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigBundle; +use hmac::Hmac; +use hmac::Mac; +use serde::Deserialize; +use serde::Serialize; +use sha2::Sha256; +use std::path::Path; +use std::time::Duration; +use thiserror::Error; +use tokio::fs; + +const CLOUD_CONFIG_BUNDLE_CACHE_VERSION: u32 = 1; +pub(super) const CLOUD_CONFIG_BUNDLE_CACHE_FILENAME: &str = "cloud-config-bundle-cache.json"; +const CLOUD_CONFIG_BUNDLE_CACHE_TTL: Duration = Duration::from_secs(30 * 60); +const CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY: &[u8] = + b"codex-cloud-config-bundle-cache-v1-6160ae70-bcfd-4ca8-a99b-40f73b3b072e"; +const CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS: &[&[u8]] = + &[CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY]; + +type HmacSha256 = Hmac; + +#[derive(Clone)] +pub(super) struct CloudConfigBundleCache { + path: AbsolutePathBuf, +} + +impl CloudConfigBundleCache { + pub(super) fn new(codex_home: AbsolutePathBuf) -> Self { + Self { + path: codex_home.join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME), + } + } + + pub(super) fn path(&self) -> &Path { + &self.path + } + + pub(super) async fn load( + &self, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + ) -> Result { + let (Some(chatgpt_user_id), Some(account_id)) = (chatgpt_user_id, account_id) else { + return Err(CacheLoadStatus::AuthIdentityIncomplete); + }; + + let bytes = match fs::read(&self.path).await { + Ok(bytes) => bytes, + Err(err) => { + if err.kind() != std::io::ErrorKind::NotFound { + return Err(CacheLoadStatus::CacheReadFailed(err.to_string())); + } + return Err(CacheLoadStatus::CacheFileNotFound); + } + }; + + let cache_file: CloudConfigBundleCacheFile = match serde_json::from_slice(&bytes) { + Ok(cache_file) => cache_file, + Err(err) => { + return Err(CacheLoadStatus::CacheParseFailed(err.to_string())); + } + }; + let payload_bytes = match cache_payload_bytes(&cache_file.signed_payload) { + Some(payload_bytes) => payload_bytes, + None => { + return Err(CacheLoadStatus::CacheParseFailed( + "failed to serialize cache payload".to_string(), + )); + } + }; + if !verify_cache_signature(&payload_bytes, &cache_file.signature) { + return Err(CacheLoadStatus::CacheSignatureInvalid); + } + if cache_file.signed_payload.version != CLOUD_CONFIG_BUNDLE_CACHE_VERSION { + return Err(CacheLoadStatus::CacheVersionUnsupported( + cache_file.signed_payload.version, + )); + } + + let (Some(cached_chatgpt_user_id), Some(cached_account_id)) = ( + cache_file.signed_payload.chatgpt_user_id.as_deref(), + cache_file.signed_payload.account_id.as_deref(), + ) else { + return Err(CacheLoadStatus::CacheIdentityIncomplete); + }; + + if cached_chatgpt_user_id != chatgpt_user_id || cached_account_id != account_id { + return Err(CacheLoadStatus::CacheIdentityMismatch); + } + + if cache_file.signed_payload.expires_at <= Utc::now() { + return Err(CacheLoadStatus::CacheExpired); + } + + Ok(cache_file.signed_payload) + } + + pub(super) fn log_load_status(&self, status: &CacheLoadStatus) { + if matches!(status, CacheLoadStatus::CacheFileNotFound) { + return; + } + + let warn = matches!( + status, + CacheLoadStatus::CacheReadFailed(_) + | CacheLoadStatus::CacheParseFailed(_) + | CacheLoadStatus::CacheSignatureInvalid + ); + + if warn { + tracing::warn!(path = %self.path.display(), "{status}"); + } else { + tracing::info!(path = %self.path.display(), "{status}"); + } + } + + pub(super) async fn save( + &self, + chatgpt_user_id: Option, + account_id: Option, + bundle: CloudConfigBundle, + ) -> Result<(), CloudConfigBundleCacheError> { + let now = Utc::now(); + let expires_at = now + .checked_add_signed( + ChronoDuration::from_std(CLOUD_CONFIG_BUNDLE_CACHE_TTL) + .map_err(|_| CloudConfigBundleCacheError)?, + ) + .ok_or(CloudConfigBundleCacheError)?; + let signed_payload = CloudConfigBundleCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, + cached_at: now, + expires_at, + chatgpt_user_id, + account_id, + bundle, + }; + let payload_bytes = + cache_payload_bytes(&signed_payload).ok_or(CloudConfigBundleCacheError)?; + let serialized = serde_json::to_vec_pretty(&CloudConfigBundleCacheFile { + signature: sign_cache_payload(&payload_bytes).ok_or(CloudConfigBundleCacheError)?, + signed_payload, + }) + .map_err(|_| CloudConfigBundleCacheError)?; + + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|_| CloudConfigBundleCacheError)?; + } + + fs::write(&self.path, serialized) + .await + .map_err(|_| CloudConfigBundleCacheError)?; + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub(super) enum CacheLoadStatus { + #[error("Skipping cloud config bundle cache read because auth identity is incomplete.")] + AuthIdentityIncomplete, + #[error("Cloud config bundle cache file not found.")] + CacheFileNotFound, + #[error("Failed to read cloud config bundle cache: {0}.")] + CacheReadFailed(String), + #[error("Failed to parse cloud config bundle cache: {0}.")] + CacheParseFailed(String), + #[error("Cloud config bundle cache failed signature verification.")] + CacheSignatureInvalid, + #[error("Ignoring cloud config bundle cache because cached identity is incomplete.")] + CacheIdentityIncomplete, + #[error("Ignoring cloud config bundle cache for different auth identity.")] + CacheIdentityMismatch, + #[error("Ignoring cloud config bundle cache with unsupported version {0}.")] + CacheVersionUnsupported(u32), + #[error("Cloud config bundle cache expired.")] + CacheExpired, + #[error("Ignoring cloud config bundle cache because the cached bundle is invalid.")] + CacheInvalidBundle, +} + +#[derive(Debug, Error)] +#[error("failed to write cloud config bundle cache")] +pub(super) struct CloudConfigBundleCacheError; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(super) struct CloudConfigBundleCacheFile { + pub(super) signed_payload: CloudConfigBundleCacheSignedPayload, + pub(super) signature: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(super) struct CloudConfigBundleCacheSignedPayload { + pub(super) version: u32, + pub(super) cached_at: DateTime, + pub(super) expires_at: DateTime, + pub(super) chatgpt_user_id: Option, + pub(super) account_id: Option, + pub(super) bundle: CloudConfigBundle, +} + +pub(super) fn cache_payload_bytes( + payload: &CloudConfigBundleCacheSignedPayload, +) -> Option> { + serde_json::to_vec(&payload).ok() +} + +pub(super) fn sign_cache_payload(payload_bytes: &[u8]) -> Option { + let mut mac = HmacSha256::new_from_slice(CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY).ok()?; + mac.update(payload_bytes); + let signature = mac.finalize().into_bytes(); + Some(BASE64_STANDARD.encode(signature)) +} + +pub(super) fn verify_cache_signature(payload_bytes: &[u8], signature: &str) -> bool { + let signature_bytes = match BASE64_STANDARD.decode(signature) { + Ok(signature_bytes) => signature_bytes, + Err(_) => return false, + }; + + CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS + .iter() + .any(|key| verify_cache_signature_with_key(payload_bytes, &signature_bytes, key)) +} + +fn verify_cache_signature_with_key( + payload_bytes: &[u8], + signature_bytes: &[u8], + key: &[u8], +) -> bool { + let mut mac = match HmacSha256::new_from_slice(key) { + Ok(mac) => mac, + Err(_) => return false, + }; + mac.update(payload_bytes); + mac.verify_slice(signature_bytes).is_ok() +} + +#[cfg(test)] +#[path = "cache_tests.rs"] +mod tests; diff --git a/codex-rs/cloud-config/src/cache_tests.rs b/codex-rs/cloud-config/src/cache_tests.rs new file mode 100644 index 0000000000..0416ed7e77 --- /dev/null +++ b/codex-rs/cloud-config/src/cache_tests.rs @@ -0,0 +1,206 @@ +use super::*; +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigFragment; +use codex_config::CloudConfigTomlBundle; +use codex_config::CloudRequirementsFragment; +use codex_config::CloudRequirementsTomlBundle; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::tempdir; + +fn test_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "cfg_1".to_string(), + name: "Base config".to_string(), + contents: "model = \"gpt-5\"".to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + }], + }, + } +} + +fn signed_cache_file( + signed_payload: CloudConfigBundleCacheSignedPayload, +) -> CloudConfigBundleCacheFile { + let payload_bytes = cache_payload_bytes(&signed_payload).expect("payload bytes"); + CloudConfigBundleCacheFile { + signature: sign_cache_payload(&payload_bytes).expect("signature"), + signed_payload, + } +} + +fn valid_signed_payload() -> CloudConfigBundleCacheSignedPayload { + let cached_at = Utc::now(); + CloudConfigBundleCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, + cached_at, + expires_at: cached_at + ChronoDuration::minutes(30), + chatgpt_user_id: Some("user-12345".to_string()), + account_id: Some("account-12345".to_string()), + bundle: test_bundle(), + } +} + +fn write_cache_file(cache: &CloudConfigBundleCache, cache_file: &CloudConfigBundleCacheFile) { + std::fs::write( + cache.path(), + serde_json::to_vec_pretty(cache_file).expect("serialize cache"), + ) + .expect("write cache"); +} + +fn create_test_cache(codex_home: &Path) -> CloudConfigBundleCache { + CloudConfigBundleCache::new(AbsolutePathBuf::resolve_path_against_base(codex_home, "/")) +} + +#[tokio::test] +async fn save_writes_signed_payload_and_loads_for_matching_identity() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + let bundle = test_bundle(); + + cache + .save( + Some("user-12345".to_string()), + Some("account-12345".to_string()), + bundle.clone(), + ) + .await + .expect("save cache"); + + let cache_file: CloudConfigBundleCacheFile = + serde_json::from_slice(&std::fs::read(cache.path()).expect("read cache")) + .expect("parse cache"); + assert!( + cache_file.signed_payload.expires_at + <= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30) + ); + assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at); + assert_eq!( + cache_file, + signed_cache_file(CloudConfigBundleCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, + cached_at: cache_file.signed_payload.cached_at, + expires_at: cache_file.signed_payload.expires_at, + chatgpt_user_id: Some("user-12345".to_string()), + account_id: Some("account-12345".to_string()), + bundle, + }) + ); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Ok(cache_file.signed_payload) + ); +} + +#[tokio::test] +async fn load_rejects_missing_request_identity_before_reading_cache_file() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + + assert_eq!( + cache + .load(/*chatgpt_user_id*/ None, Some("account-12345")) + .await, + Err(CacheLoadStatus::AuthIdentityIncomplete) + ); + assert_eq!( + cache.load(Some("user-12345"), /*account_id*/ None).await, + Err(CacheLoadStatus::AuthIdentityIncomplete) + ); +} + +#[tokio::test] +async fn load_reports_missing_and_malformed_cache_files() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheFileNotFound) + ); + + std::fs::write(cache.path(), "{").expect("write malformed cache"); + assert!(matches!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheParseFailed(_)) + )); +} + +#[tokio::test] +async fn load_rejects_tampered_payload() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + let mut cache_file = signed_cache_file(valid_signed_payload()); + cache_file + .signed_payload + .bundle + .requirements_toml + .enterprise_managed[0] + .contents = "allowed_approval_policies = [\"on-request\"]".to_string(); + write_cache_file(&cache, &cache_file); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheSignatureInvalid) + ); +} + +#[tokio::test] +async fn load_rejects_cache_for_incomplete_or_different_identity() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + let cache_file = signed_cache_file(valid_signed_payload()); + write_cache_file(&cache, &cache_file); + + assert_eq!( + cache.load(Some("user-99999"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheIdentityMismatch) + ); + + let mut signed_payload = valid_signed_payload(); + signed_payload.chatgpt_user_id = None; + write_cache_file(&cache, &signed_cache_file(signed_payload)); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheIdentityIncomplete) + ); +} + +#[tokio::test] +async fn load_rejects_expired_cache() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + let mut signed_payload = valid_signed_payload(); + signed_payload.expires_at = Utc::now() - ChronoDuration::seconds(1); + write_cache_file(&cache, &signed_cache_file(signed_payload)); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheExpired) + ); +} + +#[tokio::test] +async fn load_rejects_unsupported_cache_version() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + let mut signed_payload = valid_signed_payload(); + signed_payload.version = 2; + write_cache_file(&cache, &signed_cache_file(signed_payload)); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheVersionUnsupported(2)) + ); +} diff --git a/codex-rs/cloud-config/src/lib.rs b/codex-rs/cloud-config/src/lib.rs index 72d2b4d1cf..0e742f5522 100644 --- a/codex-rs/cloud-config/src/lib.rs +++ b/codex-rs/cloud-config/src/lib.rs @@ -3,2220 +3,12 @@ //! This crate owns transport, caching, and refresh behavior for cloud-delivered //! config data. Parsing and composition remain in `codex-config`. -use async_trait::async_trait; -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use chrono::DateTime; -use chrono::Duration as ChronoDuration; -use chrono::Utc; -use codex_backend_client::Client as BackendClient; -use codex_backend_client::ConfigBundleResponse; -use codex_backend_client::DeliveredTomlFragment; -use codex_config::AbsolutePathBuf; -use codex_config::CloudConfigBundle; -use codex_config::CloudConfigBundleLayers; -use codex_config::CloudConfigBundleLoadError; -use codex_config::CloudConfigBundleLoadErrorCode; -use codex_config::CloudConfigBundleLoader; -use codex_config::CloudConfigFragment; -use codex_config::CloudConfigTomlBundle; -use codex_config::CloudRequirementsFragment; -use codex_config::CloudRequirementsTomlBundle; -use codex_config::compose_requirements; -use codex_config::types::AuthCredentialsStoreMode; -use codex_core::util::backoff; -use codex_login::AuthManager; -use codex_login::CodexAuth; -use codex_login::RefreshTokenError; -use codex_protocol::account::PlanType; -use hmac::Hmac; -use hmac::Mac; -use serde::Deserialize; -use serde::Serialize; -use sha2::Sha256; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex; -use std::sync::OnceLock; -use std::time::Duration; -use std::time::Instant; -use thiserror::Error; -use tokio::fs; -use tokio::task::JoinHandle; -use tokio::time::sleep; -use tokio::time::timeout; - -const CLOUD_REQUIREMENTS_TIMEOUT: Duration = Duration::from_secs(15); -const CLOUD_REQUIREMENTS_MAX_ATTEMPTS: usize = 5; -const CLOUD_CONFIG_BUNDLE_CACHE_VERSION: u32 = 1; -const CLOUD_REQUIREMENTS_CACHE_FILENAME: &str = "cloud-config-bundle-cache.json"; -const CLOUD_REQUIREMENTS_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); -const CLOUD_REQUIREMENTS_CACHE_TTL: Duration = Duration::from_secs(30 * 60); -const CLOUD_REQUIREMENTS_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_config_bundle.fetch_attempt"; -const CLOUD_REQUIREMENTS_FETCH_FINAL_METRIC: &str = "codex.cloud_config_bundle.fetch_final"; -const CLOUD_REQUIREMENTS_LOAD_METRIC: &str = "codex.cloud_config_bundle.load"; -const CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE: &str = - "Failed to load cloud config bundle (workspace-managed policies)."; -const CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!( - "Your authentication session could not be refreshed automatically. ", - "Please log out and sign in again." -); -const CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY: &[u8] = - b"codex-cloud-config-bundle-cache-v1-6160ae70-bcfd-4ca8-a99b-40f73b3b072e"; -const CLOUD_REQUIREMENTS_CACHE_READ_HMAC_KEYS: &[&[u8]] = - &[CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY]; - -type HmacSha256 = Hmac; - -fn refresher_task_slot() -> &'static Mutex>> { - static REFRESHER_TASK: OnceLock>>> = OnceLock::new(); - REFRESHER_TASK.get_or_init(|| Mutex::new(None)) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum RetryableFailureKind { - BackendClientInit, - Request { status_code: Option }, -} - -impl RetryableFailureKind { - fn status_code(self) -> Option { - match self { - Self::BackendClientInit => None, - Self::Request { status_code } => status_code, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -enum FetchAttemptError { - Retryable(RetryableFailureKind), - Unauthorized { - status_code: Option, - message: String, - }, -} - -#[derive(Clone, Debug, Eq, Error, PartialEq)] -enum CacheLoadStatus { - #[error("Skipping cloud config bundle cache read because auth identity is incomplete.")] - AuthIdentityIncomplete, - #[error("Cloud config bundle cache file not found.")] - CacheFileNotFound, - #[error("Failed to read cloud config bundle cache: {0}.")] - CacheReadFailed(String), - #[error("Failed to parse cloud config bundle cache: {0}.")] - CacheParseFailed(String), - #[error("Cloud config bundle cache failed signature verification.")] - CacheSignatureInvalid, - #[error("Ignoring cloud config bundle cache because cached identity is incomplete.")] - CacheIdentityIncomplete, - #[error("Ignoring cloud config bundle cache for different auth identity.")] - CacheIdentityMismatch, - #[error("Ignoring cloud config bundle cache with unsupported version {0}.")] - CacheVersionUnsupported(u32), - #[error("Cloud config bundle cache expired.")] - CacheExpired, - #[error("Ignoring cloud config bundle cache because the cached bundle is invalid.")] - CacheInvalidBundle, -} - -#[derive(Debug, Error)] -enum CloudRequirementsError { - #[error("failed to write cloud config bundle cache")] - CacheWrite, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -struct CloudRequirementsCacheFile { - signed_payload: CloudRequirementsCacheSignedPayload, - signature: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -struct CloudRequirementsCacheSignedPayload { - version: u32, - cached_at: DateTime, - expires_at: DateTime, - chatgpt_user_id: Option, - account_id: Option, - bundle: CloudConfigBundle, -} - -fn sign_cache_payload(payload_bytes: &[u8]) -> Option { - let mut mac = HmacSha256::new_from_slice(CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY).ok()?; - mac.update(payload_bytes); - let signature = mac.finalize().into_bytes(); - Some(BASE64_STANDARD.encode(signature)) -} - -fn verify_cache_signature_with_key( - payload_bytes: &[u8], - signature_bytes: &[u8], - key: &[u8], -) -> bool { - let mut mac = match HmacSha256::new_from_slice(key) { - Ok(mac) => mac, - Err(_) => return false, - }; - mac.update(payload_bytes); - mac.verify_slice(signature_bytes).is_ok() -} - -fn verify_cache_signature(payload_bytes: &[u8], signature: &str) -> bool { - let signature_bytes = match BASE64_STANDARD.decode(signature) { - Ok(signature_bytes) => signature_bytes, - Err(_) => return false, - }; - - CLOUD_REQUIREMENTS_CACHE_READ_HMAC_KEYS - .iter() - .any(|key| verify_cache_signature_with_key(payload_bytes, &signature_bytes, key)) -} - -fn auth_identity(auth: &CodexAuth) -> (Option, Option) { - (auth.get_chatgpt_user_id(), auth.get_account_id()) -} - -fn cloud_requirements_eligible_auth(auth: &CodexAuth) -> bool { - let Some(plan_type) = auth.account_plan_type() else { - return false; - }; - auth.uses_codex_backend() - && (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) -} - -fn optional_bundle(bundle: CloudConfigBundle) -> Option { - if bundle.is_empty() { - None - } else { - Some(bundle) - } -} - -fn cache_payload_bytes(payload: &CloudRequirementsCacheSignedPayload) -> Option> { - serde_json::to_vec(&payload).ok() -} - -/// Retrieves one cloud config bundle from the backend. -/// -/// Implementations should return the backend-selected bundle exactly as delivered and leave -/// validation, caching, and config/requirements parsing decisions to the service layer. -#[async_trait] -trait RequirementsFetcher: Send + Sync { - async fn fetch_requirements( - &self, - auth: &CodexAuth, - ) -> Result; -} - -struct BackendRequirementsFetcher { - base_url: String, -} - -impl BackendRequirementsFetcher { - fn new(base_url: String) -> Self { - Self { base_url } - } -} - -#[async_trait] -impl RequirementsFetcher for BackendRequirementsFetcher { - async fn fetch_requirements( - &self, - auth: &CodexAuth, - ) -> Result { - let client = BackendClient::from_auth(self.base_url.clone(), auth) - .inspect_err(|err| { - tracing::warn!( - error = %err, - "Failed to construct backend client for cloud config bundle" - ); - }) - .map_err(|_| FetchAttemptError::Retryable(RetryableFailureKind::BackendClientInit))?; - - let response = client - .get_config_bundle() - .await - .inspect_err(|err| { - tracing::warn!(error = %err, "Failed to fetch cloud config bundle"); - }) - .map_err(|err| { - let status_code = err.status().map(|status| status.as_u16()); - if err.is_unauthorized() { - FetchAttemptError::Unauthorized { - status_code, - message: err.to_string(), - } - } else { - FetchAttemptError::Retryable(RetryableFailureKind::Request { status_code }) - } - })?; - - Ok(bundle_from_response(response)) - } -} - -fn bundle_from_response(response: ConfigBundleResponse) -> CloudConfigBundle { - let config_toml = response - .config_toml - .flatten() - .map(|config_toml| *config_toml) - .and_then(|config_toml| config_toml.enterprise_managed.flatten()) - .unwrap_or_default() - .into_iter() - .map(config_fragment_from_delivered) - .collect(); - let requirements_toml = response - .requirements_toml - .flatten() - .map(|requirements_toml| *requirements_toml) - .and_then(|requirements_toml| requirements_toml.enterprise_managed.flatten()) - .unwrap_or_default() - .into_iter() - .map(requirements_fragment_from_delivered) - .collect(); - - CloudConfigBundle { - config_toml: CloudConfigTomlBundle { - enterprise_managed: config_toml, - }, - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: requirements_toml, - }, - } -} - -fn config_fragment_from_delivered(fragment: DeliveredTomlFragment) -> CloudConfigFragment { - CloudConfigFragment { - id: fragment.id, - name: fragment.name, - contents: fragment.contents, - } -} - -fn requirements_fragment_from_delivered( - fragment: DeliveredTomlFragment, -) -> CloudRequirementsFragment { - CloudRequirementsFragment { - id: fragment.id, - name: fragment.name, - contents: fragment.contents, - } -} - -#[derive(Clone)] -struct CloudRequirementsService { - auth_manager: Arc, - fetcher: Arc, - cache_path: AbsolutePathBuf, - codex_home: AbsolutePathBuf, - timeout: Duration, -} - -impl CloudRequirementsService { - fn new( - auth_manager: Arc, - fetcher: Arc, - codex_home: PathBuf, - timeout: Duration, - ) -> Self { - let codex_home = AbsolutePathBuf::resolve_path_against_base(codex_home, "/"); - let cache_path = codex_home.join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - Self { - auth_manager, - fetcher, - cache_path, - codex_home, - timeout, - } - } - - async fn fetch_with_timeout( - &self, - ) -> Result, CloudConfigBundleLoadError> { - let _timer = - codex_otel::start_global_timer("codex.cloud_config_bundle.fetch.duration_ms", &[]); - let started_at = Instant::now(); - let load_result = timeout(self.timeout, self.fetch()) - .await - .inspect_err(|_| { - let message = format!( - "Timed out waiting for cloud config bundle after {}s", - self.timeout.as_secs() - ); - tracing::error!("{message}"); - emit_load_metric("startup", "error", /*bundle*/ None); - }) - .map_err(|_| { - CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::Timeout, - /*status_code*/ None, - format!( - "timed out waiting for cloud config bundle after {}s", - self.timeout.as_secs() - ), - ) - })?; - - let result = match load_result { - Ok(result) => result, - Err(err) => { - emit_load_metric("startup", "error", /*bundle*/ None); - return Err(err); - } - }; - - match result.as_ref() { - Some(bundle) => { - tracing::info!( - elapsed_ms = started_at.elapsed().as_millis(), - config_fragments = bundle.config_toml.enterprise_managed.len(), - requirements_fragments = bundle.requirements_toml.enterprise_managed.len(), - "Cloud config bundle load completed" - ); - emit_load_metric("startup", "success", Some(bundle)); - } - None => { - tracing::info!( - elapsed_ms = started_at.elapsed().as_millis(), - "Cloud config bundle load completed (none)" - ); - emit_load_metric("startup", "success", /*bundle*/ None); - } - } - - Ok(result) - } - - async fn fetch(&self) -> Result, CloudConfigBundleLoadError> { - let Some(auth) = self.auth_manager.auth().await else { - return Ok(None); - }; - if !cloud_requirements_eligible_auth(&auth) { - return Ok(None); - } - - let (chatgpt_user_id, account_id) = auth_identity(&auth); - match self - .load_cache(chatgpt_user_id.as_deref(), account_id.as_deref()) - .await - { - Ok(signed_payload) => { - if let Err(err) = validate_bundle(&signed_payload.bundle, &self.codex_home) { - tracing::warn!( - path = %self.cache_path.display(), - error = %err, - "Ignoring invalid cached cloud config bundle" - ); - self.log_cache_load_status(&CacheLoadStatus::CacheInvalidBundle); - } else { - tracing::info!( - path = %self.cache_path.display(), - "Using cached cloud config bundle" - ); - return Ok(optional_bundle(signed_payload.bundle)); - } - } - Err(cache_load_status) => { - self.log_cache_load_status(&cache_load_status); - } - } - - self.fetch_with_retries(auth, "startup").await - } - - async fn fetch_with_retries( - &self, - mut auth: CodexAuth, - trigger: &'static str, - ) -> Result, CloudConfigBundleLoadError> { - let mut attempt = 1; - let mut last_status_code: Option = None; - let mut auth_recovery = self.auth_manager.unauthorized_recovery(); - - while attempt <= CLOUD_REQUIREMENTS_MAX_ATTEMPTS { - let bundle = match self.fetcher.fetch_requirements(&auth).await { - Ok(bundle) => { - emit_fetch_attempt_metric( - trigger, attempt, "success", /*status_code*/ None, - ); - bundle - } - Err(FetchAttemptError::Retryable(status)) => { - let status_code = status.status_code(); - last_status_code = status_code; - emit_fetch_attempt_metric(trigger, attempt, "error", status_code); - if attempt < CLOUD_REQUIREMENTS_MAX_ATTEMPTS { - tracing::warn!( - status = ?status, - attempt, - max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Failed to fetch cloud config bundle; retrying" - ); - sleep(backoff(attempt as u64)).await; - } - attempt += 1; - continue; - } - Err(FetchAttemptError::Unauthorized { - status_code, - message, - }) => { - last_status_code = status_code; - emit_fetch_attempt_metric(trigger, attempt, "unauthorized", status_code); - if auth_recovery.has_next() { - tracing::warn!( - attempt, - max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Cloud config bundle request was unauthorized; attempting auth recovery" - ); - match auth_recovery.next().await { - Ok(_) => { - let Some(refreshed_auth) = self.auth_manager.auth().await else { - tracing::error!( - "Auth recovery succeeded but no auth is available for cloud config bundle" - ); - emit_fetch_final_metric( - trigger, - "error", - "auth_recovery_missing_auth", - attempt, - status_code, - /*bundle*/ None, - ); - return Err(CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::Auth, - status_code, - CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, - )); - }; - auth = refreshed_auth; - continue; - } - Err(RefreshTokenError::Permanent(failed)) => { - tracing::warn!( - error = %failed, - "Failed to recover from unauthorized cloud config bundle request" - ); - emit_fetch_final_metric( - trigger, - "error", - "auth_recovery_unrecoverable", - attempt, - status_code, - /*bundle*/ None, - ); - return Err(CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::Auth, - status_code, - failed.message, - )); - } - Err(RefreshTokenError::Transient(recovery_err)) => { - if attempt < CLOUD_REQUIREMENTS_MAX_ATTEMPTS { - tracing::warn!( - error = %recovery_err, - attempt, - max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Failed to recover from unauthorized cloud config bundle request; retrying" - ); - sleep(backoff(attempt as u64)).await; - } - attempt += 1; - continue; - } - } - } - - tracing::warn!( - error = %message, - "Cloud config bundle request was unauthorized and no auth recovery is available" - ); - emit_fetch_final_metric( - trigger, - "error", - "auth_recovery_unavailable", - attempt, - status_code, - /*bundle*/ None, - ); - return Err(CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::Auth, - status_code, - CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, - )); - } - }; - - if let Err(err) = validate_bundle(&bundle, &self.codex_home) { - emit_fetch_final_metric( - trigger, - "error", - "invalid_bundle", - attempt, - /*status_code*/ None, - /*bundle*/ None, - ); - return Err(err); - } - - let (chatgpt_user_id, account_id) = auth_identity(&auth); - if let Err(err) = self - .save_cache(chatgpt_user_id, account_id, bundle.clone()) - .await - { - tracing::warn!( - error = %err, - "Failed to write cloud config bundle cache" - ); - } - - emit_fetch_final_metric( - trigger, - "success", - "none", - attempt, - /*status_code*/ None, - Some(&bundle), - ); - return Ok(optional_bundle(bundle)); - } - - emit_fetch_final_metric( - trigger, - "error", - "request_retry_exhausted", - CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - last_status_code, - /*bundle*/ None, - ); - tracing::error!( - path = %self.cache_path.display(), - "{CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE}" - ); - Err(CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::RequestFailed, - last_status_code, - CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE, - )) - } - - async fn refresh_cache_in_background(&self) { - loop { - sleep(CLOUD_REQUIREMENTS_CACHE_REFRESH_INTERVAL).await; - match timeout(self.timeout, self.refresh_cache()).await { - Ok(true) => {} - Ok(false) => break, - Err(_) => { - tracing::error!( - "Timed out refreshing cloud config bundle cache from remote; keeping existing cache" - ); - emit_load_metric("refresh", "error", /*bundle*/ None); - } - } - } - } - - async fn refresh_cache(&self) -> bool { - let Some(auth) = self.auth_manager.auth().await else { - return false; - }; - if !cloud_requirements_eligible_auth(&auth) { - return false; - } - - match self.fetch_with_retries(auth, "refresh").await { - Ok(bundle) => emit_load_metric("refresh", "success", bundle.as_ref()), - Err(err) => { - tracing::error!( - path = %self.cache_path.display(), - error = %err, - "Failed to refresh cloud config bundle cache from remote" - ); - emit_load_metric("refresh", "error", /*bundle*/ None); - } - } - true - } - - async fn load_cache( - &self, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - ) -> Result { - let (Some(chatgpt_user_id), Some(account_id)) = (chatgpt_user_id, account_id) else { - return Err(CacheLoadStatus::AuthIdentityIncomplete); - }; - - let bytes = match fs::read(&self.cache_path).await { - Ok(bytes) => bytes, - Err(err) => { - if err.kind() != std::io::ErrorKind::NotFound { - return Err(CacheLoadStatus::CacheReadFailed(err.to_string())); - } - return Err(CacheLoadStatus::CacheFileNotFound); - } - }; - - let cache_file: CloudRequirementsCacheFile = match serde_json::from_slice(&bytes) { - Ok(cache_file) => cache_file, - Err(err) => { - return Err(CacheLoadStatus::CacheParseFailed(err.to_string())); - } - }; - let payload_bytes = match cache_payload_bytes(&cache_file.signed_payload) { - Some(payload_bytes) => payload_bytes, - None => { - return Err(CacheLoadStatus::CacheParseFailed( - "failed to serialize cache payload".to_string(), - )); - } - }; - if !verify_cache_signature(&payload_bytes, &cache_file.signature) { - return Err(CacheLoadStatus::CacheSignatureInvalid); - } - if cache_file.signed_payload.version != CLOUD_CONFIG_BUNDLE_CACHE_VERSION { - return Err(CacheLoadStatus::CacheVersionUnsupported( - cache_file.signed_payload.version, - )); - } - - let (Some(cached_chatgpt_user_id), Some(cached_account_id)) = ( - cache_file.signed_payload.chatgpt_user_id.as_deref(), - cache_file.signed_payload.account_id.as_deref(), - ) else { - return Err(CacheLoadStatus::CacheIdentityIncomplete); - }; - - if cached_chatgpt_user_id != chatgpt_user_id || cached_account_id != account_id { - return Err(CacheLoadStatus::CacheIdentityMismatch); - } - - if cache_file.signed_payload.expires_at <= Utc::now() { - return Err(CacheLoadStatus::CacheExpired); - } - - Ok(cache_file.signed_payload) - } - - fn log_cache_load_status(&self, status: &CacheLoadStatus) { - if matches!(status, CacheLoadStatus::CacheFileNotFound) { - return; - } - - let warn = matches!( - status, - CacheLoadStatus::CacheReadFailed(_) - | CacheLoadStatus::CacheParseFailed(_) - | CacheLoadStatus::CacheSignatureInvalid - ); - - if warn { - tracing::warn!(path = %self.cache_path.display(), "{status}"); - } else { - tracing::info!(path = %self.cache_path.display(), "{status}"); - } - } - - async fn save_cache( - &self, - chatgpt_user_id: Option, - account_id: Option, - bundle: CloudConfigBundle, - ) -> Result<(), CloudRequirementsError> { - let now = Utc::now(); - let expires_at = now - .checked_add_signed( - ChronoDuration::from_std(CLOUD_REQUIREMENTS_CACHE_TTL) - .map_err(|_| CloudRequirementsError::CacheWrite)?, - ) - .ok_or(CloudRequirementsError::CacheWrite)?; - let signed_payload = CloudRequirementsCacheSignedPayload { - version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, - cached_at: now, - expires_at, - chatgpt_user_id, - account_id, - bundle, - }; - let payload_bytes = - cache_payload_bytes(&signed_payload).ok_or(CloudRequirementsError::CacheWrite)?; - let serialized = serde_json::to_vec_pretty(&CloudRequirementsCacheFile { - signature: sign_cache_payload(&payload_bytes) - .ok_or(CloudRequirementsError::CacheWrite)?, - signed_payload, - }) - .map_err(|_| CloudRequirementsError::CacheWrite)?; - - if let Some(parent) = self.cache_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|_| CloudRequirementsError::CacheWrite)?; - } - - fs::write(&self.cache_path, serialized) - .await - .map_err(|_| CloudRequirementsError::CacheWrite)?; - Ok(()) - } -} - -pub fn cloud_config_bundle_loader( - auth_manager: Arc, - chatgpt_base_url: String, - codex_home: PathBuf, -) -> CloudConfigBundleLoader { - let service = CloudRequirementsService::new( - auth_manager, - Arc::new(BackendRequirementsFetcher::new(chatgpt_base_url)), - codex_home, - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let refresh_service = service.clone(); - let task = tokio::spawn(async move { service.fetch_with_timeout().await }); - let refresh_task = - tokio::spawn(async move { refresh_service.refresh_cache_in_background().await }); - let mut refresher_guard = refresher_task_slot().lock().unwrap_or_else(|err| { - tracing::warn!("cloud config bundle refresher task slot was poisoned"); - err.into_inner() - }); - if let Some(existing_task) = refresher_guard.replace(refresh_task) { - existing_task.abort(); - } - CloudConfigBundleLoader::new(async move { - task.await.map_err(|err| { - tracing::error!(error = %err, "Cloud config bundle task failed"); - CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::Internal, - /*status_code*/ None, - format!("cloud config bundle load failed: {err}"), - ) - })? - }) -} - -pub async fn cloud_config_bundle_loader_for_storage( - codex_home: PathBuf, - enable_codex_api_key_env: bool, - credentials_store_mode: AuthCredentialsStoreMode, - chatgpt_base_url: String, -) -> CloudConfigBundleLoader { - let auth_manager = AuthManager::shared( - codex_home.clone(), - enable_codex_api_key_env, - credentials_store_mode, - Some(chatgpt_base_url.clone()), - ) - .await; - cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home) -} - -fn validate_bundle( - bundle: &CloudConfigBundle, - base_dir: &AbsolutePathBuf, -) -> Result<(), CloudConfigBundleLoadError> { - let bundle_layers = - CloudConfigBundleLayers::from_bundle(bundle.clone(), base_dir).map_err(|err| { - CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::InvalidBundle, - /*status_code*/ None, - format!("invalid cloud config bundle: {err}"), - ) - })?; - let CloudConfigBundleLayers { - enterprise_managed_config: _, - enterprise_managed_requirements, - } = bundle_layers; - - compose_requirements(enterprise_managed_requirements).map_err(|err| { - CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::InvalidBundle, - /*status_code*/ None, - format!("invalid cloud config bundle: {err}"), - ) - })?; - - Ok(()) -} - -fn emit_fetch_attempt_metric( - trigger: &str, - attempt: usize, - outcome: &str, - status_code: Option, -) { - let attempt_tag = attempt.to_string(); - let status_code_tag = status_code_tag(status_code); - emit_metric( - CLOUD_REQUIREMENTS_FETCH_ATTEMPT_METRIC, - vec![ - ("trigger", trigger.to_string()), - ("attempt", attempt_tag), - ("outcome", outcome.to_string()), - ("status_code", status_code_tag), - ], - ); -} - -fn emit_fetch_final_metric( - trigger: &str, - outcome: &str, - reason: &str, - attempt_count: usize, - status_code: Option, - bundle: Option<&CloudConfigBundle>, -) { - let attempt_count_tag = attempt_count.to_string(); - let status_code_tag = status_code_tag(status_code); - emit_metric( - CLOUD_REQUIREMENTS_FETCH_FINAL_METRIC, - vec![ - ("trigger", trigger.to_string()), - ("outcome", outcome.to_string()), - ("reason", reason.to_string()), - ("attempt_count", attempt_count_tag), - ("status_code", status_code_tag), - ("bundle_shape", bundle_shape_tag(bundle)), - ], - ); -} - -fn emit_load_metric(trigger: &str, outcome: &str, bundle: Option<&CloudConfigBundle>) { - emit_metric( - CLOUD_REQUIREMENTS_LOAD_METRIC, - vec![ - ("trigger", trigger.to_string()), - ("outcome", outcome.to_string()), - ("bundle_shape", bundle_shape_tag(bundle)), - ], - ); -} - -fn bundle_shape_tag(bundle: Option<&CloudConfigBundle>) -> String { - let Some(bundle) = bundle else { - return "none".to_string(); - }; - - let mut sources = Vec::new(); - if !bundle.config_toml.enterprise_managed.is_empty() { - sources.push("enterprise_config"); - } - if !bundle.requirements_toml.enterprise_managed.is_empty() { - sources.push("enterprise_requirements"); - } - - if sources.is_empty() { - "empty".to_string() - } else { - sources.sort_unstable(); - sources.join(",") - } -} - -fn status_code_tag(status_code: Option) -> String { - status_code - .map(|status_code| status_code.to_string()) - .unwrap_or_else(|| "none".to_string()) -} - -fn emit_metric(metric_name: &str, tags: Vec<(&str, String)>) { - if let Some(metrics) = codex_otel::global() { - let tag_refs = tags - .iter() - .map(|(key, value)| (*key, value.as_str())) - .collect::>(); - let _ = metrics.counter(metric_name, /*inc*/ 1, &tag_refs); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use base64::Engine; - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use codex_backend_client::ConfigBundleResponse; - use codex_backend_client::DeliveredTomlFragment; - use codex_config::CloudConfigFragment; - use codex_config::CloudConfigTomlBundle; - use codex_config::CloudRequirementsFragment; - use codex_config::CloudRequirementsTomlBundle; - use codex_config::types::AuthCredentialsStoreMode; - use pretty_assertions::assert_eq; - use serde_json::json; - use std::collections::VecDeque; - use std::future::pending; - use std::path::Path; - use std::sync::atomic::AtomicUsize; - use std::sync::atomic::Ordering; - use tempfile::tempdir; - - fn write_auth_json(codex_home: &Path, value: serde_json::Value) -> std::io::Result<()> { - std::fs::write(codex_home.join("auth.json"), serde_json::to_string(&value)?)?; - Ok(()) - } - - async fn auth_manager_with_api_key() -> Arc { - let tmp = tempdir().expect("tempdir"); - let auth_json = json!({ - "OPENAI_API_KEY": "sk-test-key", - "tokens": null, - "last_refresh": null, - }); - write_auth_json(tmp.path(), auth_json).expect("write auth"); - Arc::new( - AuthManager::new( - tmp.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ) - } - - async fn auth_manager_with_plan_and_identity( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - ) -> Arc { - let tmp = tempdir().expect("tempdir"); - write_auth_json( - tmp.path(), - chatgpt_auth_json( - plan_type, - chatgpt_user_id, - account_id, - "test-access-token", - "test-refresh-token", - ), - ) - .expect("write auth"); - Arc::new( - AuthManager::new( - tmp.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ) - } - - async fn auth_manager_with_plan(plan_type: &str) -> Arc { - auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")) - .await - } - - fn chatgpt_auth_json( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - access_token: &str, - refresh_token: &str, - ) -> serde_json::Value { - chatgpt_auth_json_with_last_refresh( - plan_type, - chatgpt_user_id, - account_id, - access_token, - refresh_token, - "2025-01-01T00:00:00Z", - ) - } - - fn chatgpt_auth_json_with_last_refresh( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - access_token: &str, - refresh_token: &str, - last_refresh: &str, - ) -> serde_json::Value { - chatgpt_auth_json_with_mode( - plan_type, - chatgpt_user_id, - account_id, - access_token, - refresh_token, - last_refresh, - /*auth_mode*/ None, - ) - } - - fn chatgpt_auth_json_with_mode( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - access_token: &str, - refresh_token: &str, - last_refresh: &str, - auth_mode: Option<&str>, - ) -> serde_json::Value { - let header = json!({ "alg": "none", "typ": "JWT" }); - let auth_payload = json!({ - "chatgpt_plan_type": plan_type, - "chatgpt_user_id": chatgpt_user_id, - "user_id": chatgpt_user_id, - }); - let payload = json!({ - "email": "user@example.com", - "https://api.openai.com/auth": auth_payload, - }); - let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header")); - let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload")); - let signature_b64 = URL_SAFE_NO_PAD.encode(b"sig"); - let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - - let mut auth_json = json!({ - "OPENAI_API_KEY": null, - "tokens": { - "id_token": fake_jwt, - "access_token": access_token, - "refresh_token": refresh_token, - "account_id": account_id, - }, - "last_refresh": last_refresh, - }); - if let Some(auth_mode) = auth_mode { - auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string()); - } - auth_json - } - - fn test_bundle() -> CloudConfigBundle { - CloudConfigBundle { - config_toml: CloudConfigTomlBundle { - enterprise_managed: vec![test_config_fragment()], - }, - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: vec![test_requirements_fragment()], - }, - } - } - - fn test_config_fragment() -> CloudConfigFragment { - CloudConfigFragment { - id: "cfg_1".to_string(), - name: "Base config".to_string(), - contents: "model = \"gpt-5\"".to_string(), - } - } - - fn test_requirements_fragment() -> CloudRequirementsFragment { - CloudRequirementsFragment { - id: "req_1".to_string(), - name: "Base requirements".to_string(), - contents: "allowed_approval_policies = [\"never\"]".to_string(), - } - } - - fn invalid_config_bundle() -> CloudConfigBundle { - CloudConfigBundle { - config_toml: CloudConfigTomlBundle { - enterprise_managed: vec![CloudConfigFragment { - id: "cfg_invalid".to_string(), - name: "Invalid config".to_string(), - contents: "model = [".to_string(), - }], - }, - requirements_toml: CloudRequirementsTomlBundle::default(), - } - } - - fn request_error() -> FetchAttemptError { - FetchAttemptError::Retryable(RetryableFailureKind::Request { status_code: None }) - } - - struct StaticFetcher { - bundle: CloudConfigBundle, - request_count: AtomicUsize, - } - - impl StaticFetcher { - fn new(bundle: CloudConfigBundle) -> Self { - Self { - bundle, - request_count: AtomicUsize::new(0), - } - } - } - - #[async_trait] - impl RequirementsFetcher for StaticFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result { - self.request_count.fetch_add(1, Ordering::SeqCst); - Ok(self.bundle.clone()) - } - } - - struct PendingFetcher; - - #[async_trait] - impl RequirementsFetcher for PendingFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result { - pending::<()>().await; - Ok(CloudConfigBundle::default()) - } - } - - struct SequenceFetcher { - responses: tokio::sync::Mutex>>, - request_count: AtomicUsize, - } - - impl SequenceFetcher { - fn new(responses: Vec>) -> Self { - Self { - responses: tokio::sync::Mutex::new(VecDeque::from(responses)), - request_count: AtomicUsize::new(0), - } - } - } - - #[async_trait] - impl RequirementsFetcher for SequenceFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result { - self.request_count.fetch_add(1, Ordering::SeqCst); - let mut responses = self.responses.lock().await; - responses - .pop_front() - .unwrap_or_else(|| Ok(CloudConfigBundle::default())) - } - } - - struct TokenFetcher { - expected_token: String, - bundle: CloudConfigBundle, - request_count: AtomicUsize, - } - - #[async_trait] - impl RequirementsFetcher for TokenFetcher { - async fn fetch_requirements( - &self, - auth: &CodexAuth, - ) -> Result { - self.request_count.fetch_add(1, Ordering::SeqCst); - if matches!( - auth.get_token().as_deref(), - Ok(token) if token == self.expected_token.as_str() - ) { - Ok(self.bundle.clone()) - } else { - Err(FetchAttemptError::Unauthorized { - status_code: Some(401), - message: "GET /config/bundle failed: 401".to_string(), - }) - } - } - } - - struct UnauthorizedFetcher { - message: String, - request_count: AtomicUsize, - } - - #[async_trait] - impl RequirementsFetcher for UnauthorizedFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result { - self.request_count.fetch_add(1, Ordering::SeqCst); - Err(FetchAttemptError::Unauthorized { - status_code: Some(401), - message: self.message.clone(), - }) - } - } - - #[test] - fn bundle_shape_tag_describes_sorted_enterprise_sources() { - assert_eq!(bundle_shape_tag(/*bundle*/ None), "none"); - assert_eq!( - bundle_shape_tag(Some(&CloudConfigBundle::default())), - "empty" - ); - assert_eq!( - bundle_shape_tag(Some(&CloudConfigBundle { - config_toml: CloudConfigTomlBundle { - enterprise_managed: vec![test_config_fragment()], - }, - requirements_toml: CloudRequirementsTomlBundle::default(), - })), - "enterprise_config" - ); - assert_eq!( - bundle_shape_tag(Some(&CloudConfigBundle { - config_toml: CloudConfigTomlBundle::default(), - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: vec![test_requirements_fragment()], - }, - })), - "enterprise_requirements" - ); - assert_eq!( - bundle_shape_tag(Some(&CloudConfigBundle { - config_toml: CloudConfigTomlBundle { - enterprise_managed: vec![test_config_fragment()], - }, - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: vec![test_requirements_fragment()], - }, - })), - "enterprise_config,enterprise_requirements" - ); - } - - #[tokio::test] - async fn fetch_cloud_requirements_skips_non_chatgpt_auth() { - let fetcher = Arc::new(StaticFetcher::new(test_bundle())); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_api_key().await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(None)); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn fetch_cloud_requirements_skips_non_business_or_enterprise_plan() { - let fetcher = Arc::new(StaticFetcher::new(test_bundle())); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("pro").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(None)); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn fetch_cloud_requirements_skips_team_like_usage_based_plan() { - let fetcher = Arc::new(StaticFetcher::new(test_bundle())); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("self_serve_business_usage_based").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(None)); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn fetch_cloud_requirements_allows_business_plan() { - let bundle = test_bundle(); - let codex_home = tempdir().expect("tempdir"); - let fetcher = Arc::new(StaticFetcher::new(bundle.clone())); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(bundle.clone()))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - assert!( - codex_home - .path() - .join(CLOUD_REQUIREMENTS_CACHE_FILENAME) - .exists() - ); - } - - #[tokio::test] - async fn fetch_requirements_rejects_invalid_remote_bundle_before_cache_write() { - let codex_home = tempdir().expect("tempdir"); - let fetcher = Arc::new(StaticFetcher::new(invalid_config_bundle())); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let err = service - .fetch() - .await - .expect_err("invalid remote bundle should fail closed"); - - assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::InvalidBundle); - assert!(err.to_string().contains("invalid cloud config bundle")); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - assert!( - !codex_home - .path() - .join(CLOUD_REQUIREMENTS_CACHE_FILENAME) - .exists() - ); - } - - #[tokio::test] - async fn fetch_requirements_ignores_invalid_cache_and_refetches() { - let codex_home = tempdir().expect("tempdir"); - let replacement_bundle = test_bundle(); - let fetcher = Arc::new(StaticFetcher::new(replacement_bundle.clone())); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - service - .save_cache( - Some("user-12345".to_string()), - Some("account-12345".to_string()), - invalid_config_bundle(), - ) - .await - .expect("write invalid cache"); - - assert_eq!(service.fetch().await, Ok(Some(replacement_bundle.clone()))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - assert_eq!( - service - .load_cache(Some("user-12345"), Some("account-12345")) - .await - .expect("load refreshed cache") - .bundle, - replacement_bundle - ); - } - - #[tokio::test] - async fn fetch_cloud_requirements_allows_business_like_usage_based_plan() { - let fetcher = Arc::new(StaticFetcher::new(test_bundle())); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise_cbp_usage_based").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_allows_hc_plan_as_enterprise() { - let fetcher = Arc::new(StaticFetcher::new(test_bundle())); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("hc").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_requirements_empty_response_is_success_and_cached() { - let codex_home = tempdir().expect("tempdir"); - let fetcher = Arc::new(StaticFetcher::new(CloudConfigBundle::default())); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(None)); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - assert!( - codex_home - .path() - .join(CLOUD_REQUIREMENTS_CACHE_FILENAME) - .exists() - ); - } - - #[tokio::test] - async fn fetch_cloud_requirements_uses_cache_when_valid() { - let bundle = test_bundle(); - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher::new(bundle.clone())), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let fetcher = Arc::new(SequenceFetcher::new(vec![Err(request_error())])); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(bundle))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_cache_for_different_auth_identity() { - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - Some("user-12345"), - Some("account-12345"), - ) - .await, - Arc::new(StaticFetcher::new(test_bundle())), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let replacement_bundle = CloudConfigBundle { - config_toml: CloudConfigTomlBundle::default(), - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: vec![CloudRequirementsFragment { - id: "req_2".to_string(), - name: "Replacement requirements".to_string(), - contents: "allowed_approval_policies = [\"on-request\"]".to_string(), - }], - }, - }; - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(replacement_bundle.clone())])); - let service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - Some("user-99999"), - Some("account-12345"), - ) - .await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(replacement_bundle))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test(start_paused = true)] - async fn fetch_cloud_requirements_times_out() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - Arc::new(PendingFetcher), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let handle = tokio::spawn(async move { service.fetch_with_timeout().await }); - tokio::time::advance(CLOUD_REQUIREMENTS_TIMEOUT + Duration::from_millis(1)).await; - - let result = handle.await.expect("cloud config bundle task"); - let err = result.expect_err("cloud config bundle timeout should fail closed"); - assert!( - err.to_string() - .contains("timed out waiting for cloud config bundle") - ); - } - - #[tokio::test(start_paused = true)] - async fn fetch_cloud_requirements_retries_until_success() { - let fetcher = Arc::new(SequenceFetcher::new(vec![ - Err(request_error()), - Ok(test_bundle()), - ])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let handle = tokio::spawn(async move { service.fetch().await }); - tokio::task::yield_now().await; - tokio::time::advance(Duration::from_secs(1)).await; - - assert_eq!(handle.await.expect("bundle task"), Ok(Some(test_bundle()))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn fetch_cloud_requirements_recovers_after_unauthorized_reload() { - let auth_home = tempdir().expect("tempdir"); - write_auth_json( - auth_home.path(), - chatgpt_auth_json_with_last_refresh( - "business", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - // Keep auth "fresh" so the first request hits unauthorized recovery - // instead of AuthManager::auth() proactively reloading from disk. - "3025-01-01T00:00:00Z", - ), - ) - .expect("write initial auth"); - let auth_manager = Arc::new( - AuthManager::new( - auth_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ); - - write_auth_json( - auth_home.path(), - chatgpt_auth_json_with_last_refresh( - "business", - Some("user-12345"), - Some("account-12345"), - "fresh-access-token", - "test-refresh-token", - "3025-01-01T00:00:00Z", - ), - ) - .expect("write refreshed auth"); - let fetcher = Arc::new(TokenFetcher { - expected_token: "fresh-access-token".to_string(), - bundle: test_bundle(), - request_count: AtomicUsize::new(0), - }); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn fetch_cloud_requirements_recovers_after_unauthorized_reload_updates_cache_identity() { - let auth_home = tempdir().expect("tempdir"); - write_auth_json( - auth_home.path(), - chatgpt_auth_json_with_last_refresh( - "business", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - "3025-01-01T00:00:00Z", - ), - ) - .expect("write initial auth"); - let auth_manager = Arc::new( - AuthManager::new( - auth_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ); - - write_auth_json( - auth_home.path(), - chatgpt_auth_json_with_last_refresh( - "business", - Some("user-99999"), - Some("account-12345"), - "fresh-access-token", - "test-refresh-token", - "3025-01-01T00:00:00Z", - ), - ) - .expect("write refreshed auth"); - let fetcher = Arc::new(TokenFetcher { - expected_token: "fresh-access-token".to_string(), - bundle: test_bundle(), - request_count: AtomicUsize::new(0), - }); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); - assert_eq!( - service - .load_cache(Some("user-99999"), Some("account-12345")) - .await - .expect("load cache") - .bundle, - test_bundle() - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn fetch_cloud_requirements_surfaces_auth_recovery_message() { - let auth_home = tempdir().expect("tempdir"); - write_auth_json( - auth_home.path(), - chatgpt_auth_json( - "enterprise", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - ), - ) - .expect("write auth"); - let auth_manager = Arc::new( - AuthManager::new( - auth_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ); - - write_auth_json( - auth_home.path(), - chatgpt_auth_json( - "enterprise", - Some("user-12345"), - Some("account-99999"), - "fresh-access-token", - "test-refresh-token", - ), - ) - .expect("write mismatched auth"); - let fetcher = Arc::new(UnauthorizedFetcher { - message: "GET /config/bundle failed: 401".to_string(), - request_count: AtomicUsize::new(0), - }); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let err = service - .fetch() - .await - .expect_err("cloud config bundle should surface auth recovery errors"); - assert_eq!( - err, - CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::Auth, - Some(401), - "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.", - ) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_unauthorized_without_recovery_uses_generic_message() { - let auth_home = tempdir().expect("tempdir"); - write_auth_json( - auth_home.path(), - chatgpt_auth_json_with_mode( - "enterprise", - Some("user-12345"), - Some("account-12345"), - "test-access-token", - "test-refresh-token", - "2025-01-01T00:00:00Z", - Some("chatgptAuthTokens"), - ), - ) - .expect("write auth"); - let auth_manager = Arc::new( - AuthManager::new( - auth_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ); - - let fetcher = Arc::new(UnauthorizedFetcher { - message: - "GET https://chatgpt.com/backend-api/wham/config/bundle failed: 401; content-type=text/html; body=nope" - .to_string(), - request_count: AtomicUsize::new(0), - }); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let err = service - .fetch() - .await - .expect_err("cloud config bundle should fail closed"); - assert_eq!( - err, - CloudConfigBundleLoadError::new( - CloudConfigBundleLoadErrorCode::Auth, - Some(401), - CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, - ) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_does_not_use_cache_when_auth_identity_is_incomplete() { - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher::new(test_bundle())), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let replacement_bundle = CloudConfigBundle { - config_toml: CloudConfigTomlBundle::default(), - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: vec![CloudRequirementsFragment { - id: "req_2".to_string(), - name: "Replacement requirements".to_string(), - contents: "allowed_approval_policies = [\"on-request\"]".to_string(), - }], - }, - }; - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(replacement_bundle.clone())])); - let service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - /*chatgpt_user_id*/ None, - Some("account-12345"), - ) - .await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(replacement_bundle))); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test(start_paused = true)] - async fn fetch_cloud_requirements_stops_after_max_retries() { - let fetcher = Arc::new(SequenceFetcher::new(vec![ - Err(request_error()); - CLOUD_REQUIREMENTS_MAX_ATTEMPTS - ])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let handle = tokio::spawn(async move { service.fetch().await }); - tokio::task::yield_now().await; - tokio::time::advance(Duration::from_secs(5)).await; - tokio::task::yield_now().await; - - let err = handle - .await - .expect("cloud config bundle task") - .expect_err("cloud config bundle retry exhaustion should fail closed"); - assert_eq!(err.to_string(), CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE); - assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::RequestFailed); - assert_eq!( - fetcher.request_count.load(Ordering::SeqCst), - CLOUD_REQUIREMENTS_MAX_ATTEMPTS - ); - } - - #[tokio::test] - async fn refresh_from_remote_updates_cached_bundle() { - let replacement_bundle = CloudConfigBundle { - config_toml: CloudConfigTomlBundle::default(), - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: vec![CloudRequirementsFragment { - id: "req_2".to_string(), - name: "Replacement requirements".to_string(), - contents: "allowed_approval_policies = [\"on-request\"]".to_string(), - }], - }, - }; - let codex_home = tempdir().expect("tempdir"); - let fetcher = Arc::new(SequenceFetcher::new(vec![ - Ok(test_bundle()), - Ok(replacement_bundle.clone()), - ])); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher, - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(Some(test_bundle()))); - assert!(service.refresh_cache().await); - - let signed_payload = service - .load_cache(Some("user-12345"), Some("account-12345")) - .await - .expect("load cache"); - assert_eq!(signed_payload.bundle, replacement_bundle); - } - - #[test] - fn bundle_response_conversion_preserves_fragment_order() { - let response = ConfigBundleResponse { - config_toml: Some(Some(Box::new(codex_backend_client::DeliveredConfigToml { - enterprise_managed: Some(Some(vec![ - DeliveredTomlFragment::new( - "cfg_high".to_string(), - "High config".to_string(), - "model = \"high\"".to_string(), - ), - DeliveredTomlFragment::new( - "cfg_low".to_string(), - "Low config".to_string(), - "model = \"low\"".to_string(), - ), - ])), - }))), - requirements_toml: Some(Some(Box::new( - codex_backend_client::DeliveredRequirementsToml { - enterprise_managed: Some(Some(vec![DeliveredTomlFragment::new( - "req_high".to_string(), - "High requirements".to_string(), - "allowed_approval_policies = [\"never\"]".to_string(), - )])), - }, - ))), - }; - - assert_eq!( - bundle_from_response(response), - CloudConfigBundle { - config_toml: CloudConfigTomlBundle { - enterprise_managed: vec![ - CloudConfigFragment { - id: "cfg_high".to_string(), - name: "High config".to_string(), - contents: "model = \"high\"".to_string(), - }, - CloudConfigFragment { - id: "cfg_low".to_string(), - name: "Low config".to_string(), - contents: "model = \"low\"".to_string(), - }, - ], - }, - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: vec![CloudRequirementsFragment { - id: "req_high".to_string(), - name: "High requirements".to_string(), - contents: "allowed_approval_policies = [\"never\"]".to_string(), - }], - }, - } - ); - } - - #[test] - fn bundle_response_conversion_treats_missing_sections_as_empty() { - assert_eq!( - bundle_from_response(ConfigBundleResponse::new()), - CloudConfigBundle::default() - ); - } -} - -#[cfg(test)] -mod cache_tests { - use super::*; - use codex_config::CloudConfigFragment; - use codex_config::CloudConfigTomlBundle; - use codex_config::CloudRequirementsFragment; - use codex_config::CloudRequirementsTomlBundle; - use pretty_assertions::assert_eq; - use std::path::Path; - use tempfile::tempdir; - - fn test_bundle() -> CloudConfigBundle { - CloudConfigBundle { - config_toml: CloudConfigTomlBundle { - enterprise_managed: vec![CloudConfigFragment { - id: "cfg_1".to_string(), - name: "Base config".to_string(), - contents: "model = \"gpt-5\"".to_string(), - }], - }, - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: vec![CloudRequirementsFragment { - id: "req_1".to_string(), - name: "Base requirements".to_string(), - contents: "allowed_approval_policies = [\"never\"]".to_string(), - }], - }, - } - } - - struct NeverFetcher; - - #[async_trait] - impl RequirementsFetcher for NeverFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result { - panic!("cache tests should not fetch from remote"); - } - } - - async fn create_test_service(codex_home: &Path) -> CloudRequirementsService { - let auth_home = tempdir().expect("tempdir"); - let auth_manager = Arc::new( - AuthManager::new( - auth_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ); - CloudRequirementsService::new( - auth_manager, - Arc::new(NeverFetcher), - codex_home.to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ) - } - - fn signed_cache_file( - signed_payload: CloudRequirementsCacheSignedPayload, - ) -> CloudRequirementsCacheFile { - let payload_bytes = cache_payload_bytes(&signed_payload).expect("payload bytes"); - CloudRequirementsCacheFile { - signature: sign_cache_payload(&payload_bytes).expect("signature"), - signed_payload, - } - } - - fn valid_signed_payload() -> CloudRequirementsCacheSignedPayload { - let cached_at = Utc::now(); - CloudRequirementsCacheSignedPayload { - version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, - cached_at, - expires_at: cached_at + ChronoDuration::minutes(30), - chatgpt_user_id: Some("user-12345".to_string()), - account_id: Some("account-12345".to_string()), - bundle: test_bundle(), - } - } - - fn write_cache_file(cache_path: &Path, cache_file: &CloudRequirementsCacheFile) { - std::fs::write( - cache_path, - serde_json::to_vec_pretty(cache_file).expect("serialize cache"), - ) - .expect("write cache"); - } - - #[tokio::test] - async fn save_writes_signed_payload_and_loads_for_matching_identity() { - let codex_home = tempdir().expect("tempdir"); - let service = create_test_service(codex_home.path()).await; - let bundle = test_bundle(); - - service - .save_cache( - Some("user-12345".to_string()), - Some("account-12345".to_string()), - bundle.clone(), - ) - .await - .expect("save cache"); - - let cache_file: CloudRequirementsCacheFile = - serde_json::from_slice(&std::fs::read(&service.cache_path).expect("read cache")) - .expect("parse cache"); - assert!( - cache_file.signed_payload.expires_at - <= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30) - ); - assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at); - assert_eq!( - cache_file, - signed_cache_file(CloudRequirementsCacheSignedPayload { - version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, - cached_at: cache_file.signed_payload.cached_at, - expires_at: cache_file.signed_payload.expires_at, - chatgpt_user_id: Some("user-12345".to_string()), - account_id: Some("account-12345".to_string()), - bundle, - }) - ); - - assert_eq!( - service - .load_cache(Some("user-12345"), Some("account-12345")) - .await, - Ok(cache_file.signed_payload) - ); - } - - #[tokio::test] - async fn load_rejects_missing_request_identity_before_reading_cache_file() { - let codex_home = tempdir().expect("tempdir"); - let service = create_test_service(codex_home.path()).await; - - assert_eq!( - service - .load_cache(/*chatgpt_user_id*/ None, Some("account-12345")) - .await, - Err(CacheLoadStatus::AuthIdentityIncomplete) - ); - assert_eq!( - service - .load_cache(Some("user-12345"), /*account_id*/ None) - .await, - Err(CacheLoadStatus::AuthIdentityIncomplete) - ); - } - - #[tokio::test] - async fn load_reports_missing_and_malformed_cache_files() { - let codex_home = tempdir().expect("tempdir"); - let service = create_test_service(codex_home.path()).await; - - assert_eq!( - service - .load_cache(Some("user-12345"), Some("account-12345")) - .await, - Err(CacheLoadStatus::CacheFileNotFound) - ); - - std::fs::write(&service.cache_path, "{").expect("write malformed cache"); - assert!(matches!( - service - .load_cache(Some("user-12345"), Some("account-12345")) - .await, - Err(CacheLoadStatus::CacheParseFailed(_)) - )); - } - - #[tokio::test] - async fn load_rejects_tampered_payload() { - let codex_home = tempdir().expect("tempdir"); - let service = create_test_service(codex_home.path()).await; - let mut cache_file = signed_cache_file(valid_signed_payload()); - cache_file - .signed_payload - .bundle - .requirements_toml - .enterprise_managed[0] - .contents = "allowed_approval_policies = [\"on-request\"]".to_string(); - write_cache_file(&service.cache_path, &cache_file); - - assert_eq!( - service - .load_cache(Some("user-12345"), Some("account-12345")) - .await, - Err(CacheLoadStatus::CacheSignatureInvalid) - ); - } - - #[tokio::test] - async fn load_rejects_cache_for_incomplete_or_different_identity() { - let codex_home = tempdir().expect("tempdir"); - let service = create_test_service(codex_home.path()).await; - let cache_file = signed_cache_file(valid_signed_payload()); - write_cache_file(&service.cache_path, &cache_file); - - assert_eq!( - service - .load_cache(Some("user-99999"), Some("account-12345")) - .await, - Err(CacheLoadStatus::CacheIdentityMismatch) - ); - - let mut signed_payload = valid_signed_payload(); - signed_payload.chatgpt_user_id = None; - write_cache_file(&service.cache_path, &signed_cache_file(signed_payload)); - - assert_eq!( - service - .load_cache(Some("user-12345"), Some("account-12345")) - .await, - Err(CacheLoadStatus::CacheIdentityIncomplete) - ); - } - - #[tokio::test] - async fn load_rejects_expired_cache() { - let codex_home = tempdir().expect("tempdir"); - let service = create_test_service(codex_home.path()).await; - let mut signed_payload = valid_signed_payload(); - signed_payload.expires_at = Utc::now() - ChronoDuration::seconds(1); - write_cache_file(&service.cache_path, &signed_cache_file(signed_payload)); - - assert_eq!( - service - .load_cache(Some("user-12345"), Some("account-12345")) - .await, - Err(CacheLoadStatus::CacheExpired) - ); - } - - #[tokio::test] - async fn load_rejects_unsupported_cache_version() { - let codex_home = tempdir().expect("tempdir"); - let service = create_test_service(codex_home.path()).await; - let mut signed_payload = valid_signed_payload(); - signed_payload.version = 2; - write_cache_file(&service.cache_path, &signed_cache_file(signed_payload)); - - assert_eq!( - service - .load_cache(Some("user-12345"), Some("account-12345")) - .await, - Err(CacheLoadStatus::CacheVersionUnsupported(2)) - ); - } -} +mod backend; +mod bundle_loader; +mod cache; +mod metrics; +mod service; +mod validation; + +pub use bundle_loader::cloud_config_bundle_loader; +pub use bundle_loader::cloud_config_bundle_loader_for_storage; diff --git a/codex-rs/cloud-config/src/metrics.rs b/codex-rs/cloud-config/src/metrics.rs new file mode 100644 index 0000000000..c1c02cce9c --- /dev/null +++ b/codex-rs/cloud-config/src/metrics.rs @@ -0,0 +1,95 @@ +use codex_config::CloudConfigBundle; + +const CLOUD_CONFIG_BUNDLE_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_config_bundle.fetch_attempt"; +const CLOUD_CONFIG_BUNDLE_FETCH_FINAL_METRIC: &str = "codex.cloud_config_bundle.fetch_final"; +const CLOUD_CONFIG_BUNDLE_LOAD_METRIC: &str = "codex.cloud_config_bundle.load"; + +pub(crate) fn emit_fetch_attempt_metric( + trigger: &str, + attempt: usize, + outcome: &str, + status_code: Option, +) { + let attempt_tag = attempt.to_string(); + let status_code_tag = status_code_tag(status_code); + emit_metric( + CLOUD_CONFIG_BUNDLE_FETCH_ATTEMPT_METRIC, + vec![ + ("trigger", trigger.to_string()), + ("attempt", attempt_tag), + ("outcome", outcome.to_string()), + ("status_code", status_code_tag), + ], + ); +} + +pub(crate) fn emit_fetch_final_metric( + trigger: &str, + outcome: &str, + reason: &str, + attempt_count: usize, + status_code: Option, + bundle: Option<&CloudConfigBundle>, +) { + let attempt_count_tag = attempt_count.to_string(); + let status_code_tag = status_code_tag(status_code); + emit_metric( + CLOUD_CONFIG_BUNDLE_FETCH_FINAL_METRIC, + vec![ + ("trigger", trigger.to_string()), + ("outcome", outcome.to_string()), + ("reason", reason.to_string()), + ("attempt_count", attempt_count_tag), + ("status_code", status_code_tag), + ("bundle_shape", bundle_shape_tag(bundle)), + ], + ); +} + +pub(crate) fn emit_load_metric(trigger: &str, outcome: &str, bundle: Option<&CloudConfigBundle>) { + emit_metric( + CLOUD_CONFIG_BUNDLE_LOAD_METRIC, + vec![ + ("trigger", trigger.to_string()), + ("outcome", outcome.to_string()), + ("bundle_shape", bundle_shape_tag(bundle)), + ], + ); +} + +pub(crate) fn bundle_shape_tag(bundle: Option<&CloudConfigBundle>) -> String { + let Some(bundle) = bundle else { + return "none".to_string(); + }; + + let mut sources = Vec::new(); + if !bundle.config_toml.enterprise_managed.is_empty() { + sources.push("enterprise_config"); + } + if !bundle.requirements_toml.enterprise_managed.is_empty() { + sources.push("enterprise_requirements"); + } + + if sources.is_empty() { + "empty".to_string() + } else { + sources.sort_unstable(); + sources.join(",") + } +} + +fn status_code_tag(status_code: Option) -> String { + status_code + .map(|status_code| status_code.to_string()) + .unwrap_or_else(|| "none".to_string()) +} + +fn emit_metric(metric_name: &str, tags: Vec<(&str, String)>) { + if let Some(metrics) = codex_otel::global() { + let tag_refs = tags + .iter() + .map(|(key, value)| (*key, value.as_str())) + .collect::>(); + let _ = metrics.counter(metric_name, /*inc*/ 1, &tag_refs); + } +} diff --git a/codex-rs/cloud-config/src/service.rs b/codex-rs/cloud-config/src/service.rs new file mode 100644 index 0000000000..b0e8d49e27 --- /dev/null +++ b/codex-rs/cloud-config/src/service.rs @@ -0,0 +1,500 @@ +//! Cloud config bundle lifecycle orchestration. +//! +//! Startup loads a single shared bundle from cache or backend, and a background +//! refresher keeps the cache warm for future app starts without changing the +//! already-snapshotted runtime config. + +use crate::backend::BundleClient; +use crate::backend::BundleRequestError; +use crate::backend::RetryableFailureKind; +use crate::cache::CacheLoadStatus; +use crate::cache::CloudConfigBundleCache; +use crate::metrics::emit_fetch_attempt_metric; +use crate::metrics::emit_fetch_final_metric; +use crate::metrics::emit_load_metric; +use crate::validation::validate_bundle; +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigBundle; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoadErrorCode; +use codex_core::util::backoff; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::RefreshTokenError; +use codex_login::UnauthorizedRecovery; +use codex_protocol::account::PlanType; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; +use tokio::time::sleep; +use tokio::time::timeout; + +pub(crate) const CLOUD_CONFIG_BUNDLE_TIMEOUT: Duration = Duration::from_secs(15); +const CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS: usize = 5; +const CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); +const CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE: &str = + "Failed to load cloud config bundle (workspace-managed policies)."; +const CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!( + "Your authentication session could not be refreshed automatically. ", + "Please log out and sign in again." +); + +fn auth_identity(auth: &CodexAuth) -> (Option, Option) { + (auth.get_chatgpt_user_id(), auth.get_account_id()) +} + +fn cloud_config_eligible_auth(auth: &CodexAuth) -> bool { + let Some(plan_type) = auth.account_plan_type() else { + return false; + }; + auth.uses_codex_backend() + && (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) +} + +fn optional_bundle(bundle: CloudConfigBundle) -> Option { + if bundle.is_empty() { + None + } else { + Some(bundle) + } +} + +enum CachedBundleLookup { + Hit(Option), + Miss, +} + +enum UnauthorizedRecoveryAction { + RetrySameAttempt, + RetryNextAttempt, +} + +pub(crate) struct CloudConfigBundleService { + auth_manager: Arc, + client: Arc, + cache: CloudConfigBundleCache, + codex_home: AbsolutePathBuf, + timeout: Duration, +} + +impl Clone for CloudConfigBundleService { + fn clone(&self) -> Self { + Self { + auth_manager: Arc::clone(&self.auth_manager), + client: Arc::clone(&self.client), + cache: self.cache.clone(), + codex_home: self.codex_home.clone(), + timeout: self.timeout, + } + } +} + +impl CloudConfigBundleService +where + C: BundleClient + 'static, +{ + pub(crate) fn new( + auth_manager: Arc, + client: Arc, + codex_home: PathBuf, + timeout: Duration, + ) -> Self { + let codex_home = AbsolutePathBuf::resolve_path_against_base(codex_home, "/"); + Self { + auth_manager, + client, + cache: CloudConfigBundleCache::new(codex_home.clone()), + codex_home, + timeout, + } + } + + pub(crate) async fn load_startup_bundle_with_timeout( + &self, + ) -> Result, CloudConfigBundleLoadError> { + let _timer = + codex_otel::start_global_timer("codex.cloud_config_bundle.fetch.duration_ms", &[]); + let started_at = Instant::now(); + let load_result = timeout(self.timeout, self.load_startup_bundle()) + .await + .inspect_err(|_| { + let message = format!( + "Timed out waiting for cloud config bundle after {}s", + self.timeout.as_secs() + ); + tracing::error!("{message}"); + emit_load_metric("startup", "error", /*bundle*/ None); + }) + .map_err(|_| { + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Timeout, + /*status_code*/ None, + format!( + "timed out waiting for cloud config bundle after {}s", + self.timeout.as_secs() + ), + ) + })?; + + let result = match load_result { + Ok(result) => result, + Err(err) => { + emit_load_metric("startup", "error", /*bundle*/ None); + return Err(err); + } + }; + + match result.as_ref() { + Some(bundle) => { + tracing::info!( + elapsed_ms = started_at.elapsed().as_millis(), + config_fragments = bundle.config_toml.enterprise_managed.len(), + requirements_fragments = bundle.requirements_toml.enterprise_managed.len(), + "Cloud config bundle load completed" + ); + emit_load_metric("startup", "success", Some(bundle)); + } + None => { + tracing::info!( + elapsed_ms = started_at.elapsed().as_millis(), + "Cloud config bundle load completed (none)" + ); + emit_load_metric("startup", "success", /*bundle*/ None); + } + } + + Ok(result) + } + + async fn load_startup_bundle( + &self, + ) -> Result, CloudConfigBundleLoadError> { + let Some(auth) = self.auth_manager.auth().await else { + return Ok(None); + }; + if !cloud_config_eligible_auth(&auth) { + return Ok(None); + } + + // Startup prefers a valid, identity-matched cache entry. The backend is + // only consulted on cache miss or invalid cache contents. + let (chatgpt_user_id, account_id) = auth_identity(&auth); + match self + .load_valid_cached_bundle(chatgpt_user_id.as_deref(), account_id.as_deref()) + .await + { + CachedBundleLookup::Hit(bundle) => return Ok(bundle), + CachedBundleLookup::Miss => {} + } + + self.fetch_remote_bundle_and_update_cache_with_retries(auth, "startup") + .await + } + + async fn load_valid_cached_bundle( + &self, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + ) -> CachedBundleLookup { + match self.cache.load(chatgpt_user_id, account_id).await { + Ok(signed_payload) => { + if let Err(err) = validate_bundle(&signed_payload.bundle, &self.codex_home) { + tracing::warn!( + path = %self.cache.path().display(), + error = %err, + "Ignoring invalid cached cloud config bundle" + ); + self.cache + .log_load_status(&CacheLoadStatus::CacheInvalidBundle); + CachedBundleLookup::Miss + } else { + tracing::info!( + path = %self.cache.path().display(), + "Using cached cloud config bundle" + ); + CachedBundleLookup::Hit(optional_bundle(signed_payload.bundle)) + } + } + Err(cache_load_status) => { + self.cache.log_load_status(&cache_load_status); + CachedBundleLookup::Miss + } + } + } + + async fn fetch_remote_bundle_and_update_cache_with_retries( + &self, + mut auth: CodexAuth, + trigger: &'static str, + ) -> Result, CloudConfigBundleLoadError> { + let mut attempt = 1; + let mut last_status_code: Option = None; + let mut auth_recovery = self.auth_manager.unauthorized_recovery(); + + while attempt <= CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS { + match self.client.get_bundle(&auth).await { + Ok(bundle) => { + return self + .validate_and_cache_remote_bundle(&auth, trigger, attempt, bundle) + .await; + } + Err(BundleRequestError::Retryable(status)) => { + last_status_code = status.status_code(); + if self + .retry_after_request_failure(trigger, attempt, status) + .await + { + attempt += 1; + continue; + } + } + Err(BundleRequestError::Unauthorized { + status_code, + message, + }) => { + last_status_code = status_code; + match self + .handle_unauthorized( + &mut auth, + &mut auth_recovery, + trigger, + attempt, + status_code, + &message, + ) + .await? + { + UnauthorizedRecoveryAction::RetrySameAttempt => continue, + UnauthorizedRecoveryAction::RetryNextAttempt => { + attempt += 1; + continue; + } + } + } + } + + break; + } + + emit_fetch_final_metric( + trigger, + "error", + "request_retry_exhausted", + CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS, + last_status_code, + /*bundle*/ None, + ); + tracing::error!( + path = %self.cache.path().display(), + "{CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE}" + ); + Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::RequestFailed, + last_status_code, + CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE, + )) + } + + async fn validate_and_cache_remote_bundle( + &self, + auth: &CodexAuth, + trigger: &'static str, + attempt: usize, + bundle: CloudConfigBundle, + ) -> Result, CloudConfigBundleLoadError> { + emit_fetch_attempt_metric(trigger, attempt, "success", /*status_code*/ None); + if let Err(err) = validate_bundle(&bundle, &self.codex_home) { + emit_fetch_final_metric( + trigger, + "error", + "invalid_bundle", + attempt, + /*status_code*/ None, + /*bundle*/ None, + ); + return Err(err); + } + + let (chatgpt_user_id, account_id) = auth_identity(auth); + if let Err(err) = self + .cache + .save(chatgpt_user_id, account_id, bundle.clone()) + .await + { + tracing::warn!( + error = %err, + "Failed to write cloud config bundle cache" + ); + } + + emit_fetch_final_metric( + trigger, + "success", + "none", + attempt, + /*status_code*/ None, + Some(&bundle), + ); + Ok(optional_bundle(bundle)) + } + + async fn retry_after_request_failure( + &self, + trigger: &'static str, + attempt: usize, + status: RetryableFailureKind, + ) -> bool { + let status_code = status.status_code(); + emit_fetch_attempt_metric(trigger, attempt, "error", status_code); + if attempt < CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS { + tracing::warn!( + status = ?status, + attempt, + max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS, + "Failed to fetch cloud config bundle; retrying" + ); + sleep(backoff(attempt as u64)).await; + true + } else { + false + } + } + + async fn handle_unauthorized( + &self, + auth: &mut CodexAuth, + auth_recovery: &mut UnauthorizedRecovery, + trigger: &'static str, + attempt: usize, + status_code: Option, + message: &str, + ) -> Result { + emit_fetch_attempt_metric(trigger, attempt, "unauthorized", status_code); + if auth_recovery.has_next() { + tracing::warn!( + attempt, + max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS, + "Cloud config bundle request was unauthorized; attempting auth recovery" + ); + match auth_recovery.next().await { + Ok(_) => { + let Some(refreshed_auth) = self.auth_manager.auth().await else { + tracing::error!( + "Auth recovery succeeded but no auth is available for cloud config bundle" + ); + emit_fetch_final_metric( + trigger, + "error", + "auth_recovery_missing_auth", + attempt, + status_code, + /*bundle*/ None, + ); + return Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + status_code, + CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE, + )); + }; + *auth = refreshed_auth; + return Ok(UnauthorizedRecoveryAction::RetrySameAttempt); + } + Err(RefreshTokenError::Permanent(failed)) => { + tracing::warn!( + error = %failed, + "Failed to recover from unauthorized cloud config bundle request" + ); + emit_fetch_final_metric( + trigger, + "error", + "auth_recovery_unrecoverable", + attempt, + status_code, + /*bundle*/ None, + ); + return Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + status_code, + failed.message, + )); + } + Err(RefreshTokenError::Transient(recovery_err)) => { + if attempt < CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS { + tracing::warn!( + error = %recovery_err, + attempt, + max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS, + "Failed to recover from unauthorized cloud config bundle request; retrying" + ); + sleep(backoff(attempt as u64)).await; + } + return Ok(UnauthorizedRecoveryAction::RetryNextAttempt); + } + } + } + + tracing::warn!( + error = %message, + "Cloud config bundle request was unauthorized and no auth recovery is available" + ); + emit_fetch_final_metric( + trigger, + "error", + "auth_recovery_unavailable", + attempt, + status_code, + /*bundle*/ None, + ); + Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + status_code, + CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE, + )) + } + + pub(crate) async fn refresh_cache_in_background(&self) { + loop { + sleep(CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL).await; + match timeout(self.timeout, self.refresh_cache_once()).await { + Ok(true) => {} + Ok(false) => break, + Err(_) => { + tracing::error!( + "Timed out refreshing cloud config bundle cache from remote; keeping existing cache" + ); + emit_load_metric("refresh", "error", /*bundle*/ None); + } + } + } + } + + async fn refresh_cache_once(&self) -> bool { + let Some(auth) = self.auth_manager.auth().await else { + return false; + }; + if !cloud_config_eligible_auth(&auth) { + return false; + } + + match self + .fetch_remote_bundle_and_update_cache_with_retries(auth, "refresh") + .await + { + Ok(bundle) => emit_load_metric("refresh", "success", bundle.as_ref()), + Err(err) => { + tracing::error!( + path = %self.cache.path().display(), + error = %err, + "Failed to refresh cloud config bundle cache from remote" + ); + emit_load_metric("refresh", "error", /*bundle*/ None); + } + } + true + } +} + +#[cfg(test)] +#[path = "service_tests.rs"] +mod tests; diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs new file mode 100644 index 0000000000..a07443ba0c --- /dev/null +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -0,0 +1,1035 @@ +use super::*; +use crate::backend::BundleClient; +use crate::backend::BundleRequestError; +use crate::backend::RetryableFailureKind; +use crate::backend::bundle_from_response; +use crate::cache::CLOUD_CONFIG_BUNDLE_CACHE_FILENAME; +use crate::cache::CloudConfigBundleCache; +use crate::metrics::bundle_shape_tag; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use codex_backend_client::ConfigBundleResponse; +use codex_backend_client::DeliveredTomlFragment; +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigFragment; +use codex_config::CloudConfigTomlBundle; +use codex_config::CloudRequirementsFragment; +use codex_config::CloudRequirementsTomlBundle; +use codex_config::types::AuthCredentialsStoreMode; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::VecDeque; +use std::future::pending; +use std::path::Path; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use tempfile::tempdir; + +fn write_auth_json(codex_home: &Path, value: serde_json::Value) -> std::io::Result<()> { + std::fs::write(codex_home.join("auth.json"), serde_json::to_string(&value)?)?; + Ok(()) +} + +fn create_test_cache(codex_home: &Path) -> CloudConfigBundleCache { + CloudConfigBundleCache::new(AbsolutePathBuf::resolve_path_against_base(codex_home, "/")) +} + +async fn auth_manager_with_api_key() -> Arc { + let tmp = tempdir().expect("tempdir"); + let auth_json = json!({ + "OPENAI_API_KEY": "sk-test-key", + "tokens": null, + "last_refresh": null, + }); + write_auth_json(tmp.path(), auth_json).expect("write auth"); + Arc::new( + AuthManager::new( + tmp.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ) +} + +async fn auth_manager_with_plan_and_identity( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, +) -> Arc { + let tmp = tempdir().expect("tempdir"); + write_auth_json( + tmp.path(), + chatgpt_auth_json( + plan_type, + chatgpt_user_id, + account_id, + "test-access-token", + "test-refresh-token", + ), + ) + .expect("write auth"); + Arc::new( + AuthManager::new( + tmp.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ) +} + +async fn auth_manager_with_plan(plan_type: &str) -> Arc { + auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")).await +} + +fn chatgpt_auth_json( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, +) -> serde_json::Value { + chatgpt_auth_json_with_last_refresh( + plan_type, + chatgpt_user_id, + account_id, + access_token, + refresh_token, + "2025-01-01T00:00:00Z", + ) +} + +fn chatgpt_auth_json_with_last_refresh( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, + last_refresh: &str, +) -> serde_json::Value { + chatgpt_auth_json_with_mode( + plan_type, + chatgpt_user_id, + account_id, + access_token, + refresh_token, + last_refresh, + /*auth_mode*/ None, + ) +} + +fn chatgpt_auth_json_with_mode( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, + last_refresh: &str, + auth_mode: Option<&str>, +) -> serde_json::Value { + let header = json!({ "alg": "none", "typ": "JWT" }); + let auth_payload = json!({ + "chatgpt_plan_type": plan_type, + "chatgpt_user_id": chatgpt_user_id, + "user_id": chatgpt_user_id, + }); + let payload = json!({ + "email": "user@example.com", + "https://api.openai.com/auth": auth_payload, + }); + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header")); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload")); + let signature_b64 = URL_SAFE_NO_PAD.encode(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let mut auth_json = json!({ + "OPENAI_API_KEY": null, + "tokens": { + "id_token": fake_jwt, + "access_token": access_token, + "refresh_token": refresh_token, + "account_id": account_id, + }, + "last_refresh": last_refresh, + }); + if let Some(auth_mode) = auth_mode { + auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string()); + } + auth_json +} + +fn test_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, + } +} + +fn test_config_fragment() -> CloudConfigFragment { + CloudConfigFragment { + id: "cfg_1".to_string(), + name: "Base config".to_string(), + contents: "model = \"gpt-5\"".to_string(), + } +} + +fn test_requirements_fragment() -> CloudRequirementsFragment { + CloudRequirementsFragment { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + } +} + +fn invalid_config_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "cfg_invalid".to_string(), + name: "Invalid config".to_string(), + contents: "model = [".to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle::default(), + } +} + +fn request_error() -> BundleRequestError { + BundleRequestError::Retryable(RetryableFailureKind::Request { status_code: None }) +} + +struct StaticBundleClient { + bundle: CloudConfigBundle, + request_count: AtomicUsize, +} + +impl StaticBundleClient { + fn new(bundle: CloudConfigBundle) -> Self { + Self { + bundle, + request_count: AtomicUsize::new(0), + } + } +} + +impl BundleClient for StaticBundleClient { + async fn get_bundle(&self, _auth: &CodexAuth) -> Result { + self.request_count.fetch_add(1, Ordering::SeqCst); + Ok(self.bundle.clone()) + } +} + +struct PendingBundleClient; + +impl BundleClient for PendingBundleClient { + async fn get_bundle(&self, _auth: &CodexAuth) -> Result { + pending::<()>().await; + Ok(CloudConfigBundle::default()) + } +} + +struct SequenceBundleClient { + responses: tokio::sync::Mutex>>, + request_count: AtomicUsize, +} + +impl SequenceBundleClient { + fn new(responses: Vec>) -> Self { + Self { + responses: tokio::sync::Mutex::new(VecDeque::from(responses)), + request_count: AtomicUsize::new(0), + } + } +} + +impl BundleClient for SequenceBundleClient { + async fn get_bundle(&self, _auth: &CodexAuth) -> Result { + self.request_count.fetch_add(1, Ordering::SeqCst); + let mut responses = self.responses.lock().await; + responses + .pop_front() + .unwrap_or_else(|| Ok(CloudConfigBundle::default())) + } +} + +struct TokenBundleClient { + expected_token: String, + bundle: CloudConfigBundle, + request_count: AtomicUsize, +} + +impl BundleClient for TokenBundleClient { + async fn get_bundle(&self, auth: &CodexAuth) -> Result { + self.request_count.fetch_add(1, Ordering::SeqCst); + if matches!( + auth.get_token().as_deref(), + Ok(token) if token == self.expected_token.as_str() + ) { + Ok(self.bundle.clone()) + } else { + Err(BundleRequestError::Unauthorized { + status_code: Some(401), + message: "GET /config/bundle failed: 401".to_string(), + }) + } + } +} + +struct UnauthorizedBundleClient { + message: String, + request_count: AtomicUsize, +} + +impl BundleClient for UnauthorizedBundleClient { + async fn get_bundle(&self, _auth: &CodexAuth) -> Result { + self.request_count.fetch_add(1, Ordering::SeqCst); + Err(BundleRequestError::Unauthorized { + status_code: Some(401), + message: self.message.clone(), + }) + } +} + +#[test] +fn bundle_shape_tag_describes_sorted_enterprise_sources() { + assert_eq!(bundle_shape_tag(/*bundle*/ None), "none"); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle::default())), + "empty" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle::default(), + })), + "enterprise_config" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, + })), + "enterprise_requirements" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, + })), + "enterprise_config,enterprise_requirements" + ); +} + +#[tokio::test] +async fn get_bundle_skips_non_chatgpt_auth() { + let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_api_key().await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn get_bundle_skips_non_business_or_enterprise_plan() { + let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("pro").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn get_bundle_skips_team_like_usage_based_plan() { + let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("self_serve_business_usage_based").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn get_bundle_allows_business_plan_and_writes_cache() { + let bundle = test_bundle(); + let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticBundleClient::new(bundle.clone())); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!( + service.load_startup_bundle().await, + Ok(Some(bundle.clone())) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + codex_home + .path() + .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME) + .exists() + ); +} + +#[tokio::test] +async fn get_bundle_rejects_invalid_remote_bundle_before_cache_write() { + let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticBundleClient::new(invalid_config_bundle())); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + let err = service + .load_startup_bundle() + .await + .expect_err("invalid remote bundle should fail closed"); + + assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::InvalidBundle); + assert!(err.to_string().contains("invalid cloud config bundle")); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + !codex_home + .path() + .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME) + .exists() + ); +} + +#[tokio::test] +async fn get_bundle_ignores_invalid_cache_and_refetches() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + cache + .save( + Some("user-12345".to_string()), + Some("account-12345".to_string()), + invalid_config_bundle(), + ) + .await + .expect("write invalid cache"); + let replacement_bundle = test_bundle(); + let fetcher = Arc::new(StaticBundleClient::new(replacement_bundle.clone())); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!( + service.load_startup_bundle().await, + Ok(Some(replacement_bundle.clone())) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert_eq!( + cache + .load(Some("user-12345"), Some("account-12345")) + .await + .expect("load refreshed cache") + .bundle, + replacement_bundle + ); +} + +#[tokio::test] +async fn get_bundle_allows_business_like_usage_based_plan() { + let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("enterprise_cbp_usage_based").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn get_bundle_allows_hc_plan_as_enterprise() { + let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("hc").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn get_bundle_empty_response_is_success_and_cached() { + let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticBundleClient::new(CloudConfigBundle::default())); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("enterprise").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + codex_home + .path() + .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME) + .exists() + ); +} + +#[tokio::test] +async fn get_bundle_uses_cache_when_valid() { + let bundle = test_bundle(); + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + Arc::new(StaticBundleClient::new(bundle.clone())), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + let _ = prime_service.load_startup_bundle().await; + + let fetcher = Arc::new(SequenceBundleClient::new(vec![Err(request_error())])); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(bundle))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn get_bundle_ignores_cache_for_different_auth_identity() { + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudConfigBundleService::new( + auth_manager_with_plan_and_identity("business", Some("user-12345"), Some("account-12345")) + .await, + Arc::new(StaticBundleClient::new(test_bundle())), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + let _ = prime_service.load_startup_bundle().await; + + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; + let fetcher = Arc::new(SequenceBundleClient::new(vec![Ok( + replacement_bundle.clone() + )])); + let service = CloudConfigBundleService::new( + auth_manager_with_plan_and_identity("business", Some("user-99999"), Some("account-12345")) + .await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!( + service.load_startup_bundle().await, + Ok(Some(replacement_bundle)) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test(start_paused = true)] +async fn get_bundle_times_out() { + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("enterprise").await, + Arc::new(PendingBundleClient), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + let handle = tokio::spawn(async move { service.load_startup_bundle_with_timeout().await }); + tokio::time::advance(CLOUD_CONFIG_BUNDLE_TIMEOUT + Duration::from_millis(1)).await; + + let result = handle.await.expect("cloud config bundle task"); + let err = result.expect_err("cloud config bundle timeout should fail closed"); + assert!( + err.to_string() + .contains("timed out waiting for cloud config bundle") + ); +} + +#[tokio::test(start_paused = true)] +async fn get_bundle_retries_until_success() { + let fetcher = Arc::new(SequenceBundleClient::new(vec![ + Err(request_error()), + Ok(test_bundle()), + ])); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + let handle = tokio::spawn(async move { service.load_startup_bundle().await }); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(1)).await; + + assert_eq!(handle.await.expect("bundle task"), Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn get_bundle_recovers_after_unauthorized_reload() { + let auth_home = tempdir().expect("tempdir"); + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + // Keep auth "fresh" so the first request hits unauthorized recovery + // instead of AuthManager::auth() proactively reloading from disk. + "3025-01-01T00:00:00Z", + ), + ) + .expect("write initial auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "fresh-access-token", + "test-refresh-token", + "3025-01-01T00:00:00Z", + ), + ) + .expect("write refreshed auth"); + let fetcher = Arc::new(TokenBundleClient { + expected_token: "fresh-access-token".to_string(), + bundle: test_bundle(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn get_bundle_recovers_after_unauthorized_reload_updates_cache_identity() { + let auth_home = tempdir().expect("tempdir"); + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + "3025-01-01T00:00:00Z", + ), + ) + .expect("write initial auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-99999"), + Some("account-12345"), + "fresh-access-token", + "test-refresh-token", + "3025-01-01T00:00:00Z", + ), + ) + .expect("write refreshed auth"); + let fetcher = Arc::new(TokenBundleClient { + expected_token: "fresh-access-token".to_string(), + bundle: test_bundle(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); + let cache = create_test_cache(codex_home.path()); + assert_eq!( + cache + .load(Some("user-99999"), Some("account-12345")) + .await + .expect("load cache") + .bundle, + test_bundle() + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn get_bundle_surfaces_auth_recovery_message() { + let auth_home = tempdir().expect("tempdir"); + write_auth_json( + auth_home.path(), + chatgpt_auth_json( + "enterprise", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + ), + ) + .expect("write auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + write_auth_json( + auth_home.path(), + chatgpt_auth_json( + "enterprise", + Some("user-12345"), + Some("account-99999"), + "fresh-access-token", + "test-refresh-token", + ), + ) + .expect("write mismatched auth"); + let fetcher = Arc::new(UnauthorizedBundleClient { + message: "GET /config/bundle failed: 401".to_string(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + let err = service + .load_startup_bundle() + .await + .expect_err("cloud config bundle should surface auth recovery errors"); + assert_eq!( + err, + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + Some(401), + "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.", + ) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn get_bundle_unauthorized_without_recovery_uses_generic_message() { + let auth_home = tempdir().expect("tempdir"); + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_mode( + "enterprise", + Some("user-12345"), + Some("account-12345"), + "test-access-token", + "test-refresh-token", + "2025-01-01T00:00:00Z", + Some("chatgptAuthTokens"), + ), + ) + .expect("write auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + let fetcher = Arc::new(UnauthorizedBundleClient { + message: + "GET https://chatgpt.com/backend-api/wham/config/bundle failed: 401; content-type=text/html; body=nope" + .to_string(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + let err = service + .load_startup_bundle() + .await + .expect_err("cloud config bundle should fail closed"); + assert_eq!( + err, + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + Some(401), + CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE, + ) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn get_bundle_does_not_use_cache_when_auth_identity_is_incomplete() { + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + Arc::new(StaticBundleClient::new(test_bundle())), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + let _ = prime_service.load_startup_bundle().await; + + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; + let fetcher = Arc::new(SequenceBundleClient::new(vec![Ok( + replacement_bundle.clone() + )])); + let service = CloudConfigBundleService::new( + auth_manager_with_plan_and_identity( + "business", + /*chatgpt_user_id*/ None, + Some("account-12345"), + ) + .await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!( + service.load_startup_bundle().await, + Ok(Some(replacement_bundle)) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test(start_paused = true)] +async fn get_bundle_stops_after_max_retries() { + let fetcher = Arc::new(SequenceBundleClient::new(vec![ + Err(request_error()); + CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS + ])); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("enterprise").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + let handle = tokio::spawn(async move { service.load_startup_bundle().await }); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(5)).await; + tokio::task::yield_now().await; + + let err = handle + .await + .expect("cloud config bundle task") + .expect_err("cloud config bundle retry exhaustion should fail closed"); + assert_eq!(err.to_string(), CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE); + assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::RequestFailed); + assert_eq!( + fetcher.request_count.load(Ordering::SeqCst), + CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS + ); +} + +#[tokio::test] +async fn refresh_from_remote_updates_cached_bundle() { + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; + let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(SequenceBundleClient::new(vec![ + Ok(test_bundle()), + Ok(replacement_bundle.clone()), + ])); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher, + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); + assert!(service.refresh_cache_once().await); + + let cache = create_test_cache(codex_home.path()); + let signed_payload = cache + .load(Some("user-12345"), Some("account-12345")) + .await + .expect("load cache"); + assert_eq!(signed_payload.bundle, replacement_bundle); +} + +#[test] +fn bundle_response_conversion_preserves_fragment_order() { + let response = ConfigBundleResponse { + config_toml: Some(Some(Box::new(codex_backend_client::DeliveredConfigToml { + enterprise_managed: Some(Some(vec![ + DeliveredTomlFragment::new( + "cfg_high".to_string(), + "High config".to_string(), + "model = \"high\"".to_string(), + ), + DeliveredTomlFragment::new( + "cfg_low".to_string(), + "Low config".to_string(), + "model = \"low\"".to_string(), + ), + ])), + }))), + requirements_toml: Some(Some(Box::new( + codex_backend_client::DeliveredRequirementsToml { + enterprise_managed: Some(Some(vec![DeliveredTomlFragment::new( + "req_high".to_string(), + "High requirements".to_string(), + "allowed_approval_policies = [\"never\"]".to_string(), + )])), + }, + ))), + }; + + assert_eq!( + bundle_from_response(response), + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![ + CloudConfigFragment { + id: "cfg_high".to_string(), + name: "High config".to_string(), + contents: "model = \"high\"".to_string(), + }, + CloudConfigFragment { + id: "cfg_low".to_string(), + name: "Low config".to_string(), + contents: "model = \"low\"".to_string(), + }, + ], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_high".to_string(), + name: "High requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + }], + }, + } + ); +} + +#[test] +fn bundle_response_conversion_treats_missing_sections_as_empty() { + assert_eq!( + bundle_from_response(ConfigBundleResponse::new()), + CloudConfigBundle::default() + ); +} diff --git a/codex-rs/cloud-config/src/validation.rs b/codex-rs/cloud-config/src/validation.rs new file mode 100644 index 0000000000..ef5ed06e1a --- /dev/null +++ b/codex-rs/cloud-config/src/validation.rs @@ -0,0 +1,34 @@ +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigBundle; +use codex_config::CloudConfigBundleLayers; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoadErrorCode; +use codex_config::compose_requirements; + +pub(crate) fn validate_bundle( + bundle: &CloudConfigBundle, + base_dir: &AbsolutePathBuf, +) -> Result<(), CloudConfigBundleLoadError> { + let bundle_layers = + CloudConfigBundleLayers::from_bundle(bundle.clone(), base_dir).map_err(|err| { + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::InvalidBundle, + /*status_code*/ None, + format!("invalid cloud config bundle: {err}"), + ) + })?; + let CloudConfigBundleLayers { + enterprise_managed_config: _, + enterprise_managed_requirements, + } = bundle_layers; + + compose_requirements(enterprise_managed_requirements).map_err(|err| { + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::InvalidBundle, + /*status_code*/ None, + format!("invalid cloud config bundle: {err}"), + ) + })?; + + Ok(()) +} From 68e2c8ed69ce994a0da02c2dcf8b20a69f43c2b6 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 2 Jun 2026 14:50:16 -0700 Subject: [PATCH 18/19] [codex] Keep hosted tools visible in code-only mode (#25890) ## Why `code_mode_only` moved ordinary runtime tools behind `exec`, but it also hid hosted Responses tools. Hosted `web_search` and `image_generation` do not have a nested `exec` runtime path, so code-only sessions lost those capabilities entirely even when their existing provider, auth, model, and configuration gates passed. ## What changed - Keep hosted Responses tools top-level in `code_mode_only` sessions after their existing gates pass. - Preserve the existing nested-tool behavior for ordinary runtimes and the direct-only behavior for multi-agent v2 tools. - Add planner coverage for `code_mode_only` with default multi-agent v2 settings, hosted live web search, and hosted image generation. ## Verification - Added focused regression coverage in `codex-rs/core/src/tools/spec_plan_tests.rs`. - Left execution to CI per repository workflow. --- codex-rs/core/src/tools/spec_plan.rs | 11 +----- codex-rs/core/src/tools/spec_plan_tests.rs | 37 ++++++++++++++++++- .../tests/suite/model_runtime_selectors.rs | 6 +++ 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 9d5aa8f8ee..48111fb578 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -210,15 +210,7 @@ fn build_model_visible_specs_and_registry( specs.push(spec_for_model_request(turn_context, exposure, spec)); } } - for spec in hosted_specs { - if !is_hidden_by_code_mode_only( - turn_context, - &ToolName::plain(spec.name()), - ToolExposure::Direct, - ) { - specs.push(spec); - } - } + specs.extend(hosted_specs); let registry = ToolRegistry::from_tools(runtimes); let model_visible_specs = merge_into_namespaces(specs) @@ -267,6 +259,7 @@ fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec { }) { specs.push(web_search_tool); } + // TODO: Remove hosted image generation once the standalone extension is ready. if image_generation_tool_enabled(turn_context) && !standalone_image_generation_available(turn_context, context.extension_tool_executors) { diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 1cb36e474d..02a7a21f63 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1167,7 +1167,16 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { }) .await; - assert_eq!(plan.visible_names, vec!["exec", "wait", "agents"]); + assert_eq!( + plan.visible_names, + vec![ + "exec", + "wait", + "agents", + // Hosted Responses tools. + "web_search", + ] + ); assert!( !plan .namespace_function_names("agents") @@ -1235,6 +1244,32 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { } ); + let code_mode_only = probe(|turn| { + use_chatgpt_auth(turn); + set_features(turn, &[Feature::CodeModeOnly, Feature::MultiAgentV2]); + set_web_search_mode(turn, WebSearchMode::Live); + turn.model_info.input_modalities = vec![InputModality::Image]; + }) + .await; + assert_eq!( + code_mode_only.visible_names, + vec![ + // Code-mode entrypoints. + codex_code_mode::PUBLIC_TOOL_NAME, + codex_code_mode::WAIT_TOOL_NAME, + // Multi-agent v2 tools. + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "close_agent", + "list_agents", + // Hosted Responses tools. + "web_search", + "image_generation", + ] + ); + let standalone_web_search_without_web_run = probe(|turn| { set_feature(turn, Feature::StandaloneWebSearch, /*enabled*/ true); set_web_search_mode(turn, WebSearchMode::Live); diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index 06385f6561..c17121b521 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -5,6 +5,7 @@ use codex_login::CodexAuth; use codex_models_manager::manager::RefreshStrategy; use codex_models_manager::manager::SharedModelsManager; use codex_models_manager::model_info::model_info_from_slug; +use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelVisibility; @@ -165,12 +166,17 @@ async fn remote_tool_mode_selector_overrides_feature_flags() -> Result<()> { let mut code_mode_only_model = remote_model("test-tool-mode-code-mode-only"); code_mode_only_model.tool_mode = Some(ToolMode::CodeModeOnly); + code_mode_only_model.input_modalities = vec![InputModality::Text, InputModality::Image]; let code_mode_only_body = response_body_for_remote_model(code_mode_only_model, |_| {}).await?; assert_eq!( tool_names(&code_mode_only_body), vec![ + // Code-mode entrypoints. codex_code_mode::PUBLIC_TOOL_NAME.to_string(), codex_code_mode::WAIT_TOOL_NAME.to_string(), + // Hosted Responses tools. + "web_search".to_string(), + "image_generation".to_string(), ] ); From d55e5a9bde03235fbd796282e839966c5b39ec80 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 2 Jun 2026 23:55:08 +0200 Subject: [PATCH 19/19] Add remote request permissions integration coverage (#25867) ## Stack 1. #25850 - Key request-permission grants by environment: stores and applies sticky permission grants per environment id. 2. #25858 - Add `environmentId` to `request_permissions`: lets the model target a selected environment and resolves relative permission paths against it. 3. #25862 - Propagate permission approval environment id: carries the selected environment id through approval events, app-server requests, TUI prompts, and delegate forwarding. 4. This PR (#25867) - Add remote request permissions integration coverage: verifies the selected remote environment across request, approval, grant reuse, and exec. This PR is stacked on #25862 and should be reviewed after #25850, #25858, and #25862. ## Why The environment-scoped permission stack needs one end-to-end check that exercises the CCA-shaped path, not only unit-level parsing. This verifies that a model-sent `environmentId` on `request_permissions` reaches the approval event, stores the grant under the selected environment, and is reused by a later tool call in that same environment. ## What Changed - Adds a remote executor integration test for `request_permissions` with `environmentId: remote` and a relative write root. - Asserts the permission event reports the remote environment and cwd, and that the normalized grant resolves under the remote cwd. - Approves the grant, then runs a remote `exec_command` without explicit per-call permissions and verifies it completes without another exec approval and writes only in the remote filesystem. ## Verification - Not run locally per instruction. - `git diff --check` --- codex-rs/core/tests/suite/remote_env.rs | 207 ++++++++++++++++++++++++ 1 file changed, 207 insertions(+) diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 34cd119f66..0862482f9d 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -9,6 +9,7 @@ use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_exec_server::RemoveOptions; use codex_features::Feature; +use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -22,6 +23,9 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile; +use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; @@ -345,6 +349,209 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result<()> { + skip_if_no_network!(Ok(())); + let Some(_remote_env) = get_remote_test_env() else { + return Ok(()); + }; + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.approvals_reviewer = ApprovalsReviewer::User; + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::ExecPermissionApprovals) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); + }); + let test = builder.build_with_remote_and_local_env(&server).await?; + + let local_cwd = TempDir::new()?; + let remote_cwd = PathBuf::from(format!( + "/tmp/codex-remote-request-permissions-{}", + SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() + )) + .abs(); + let relative_write_root = "granted"; + let relative_target_path = "granted/request-permissions-output.txt"; + let remote_write_root = remote_cwd.join(relative_write_root); + let remote_target_path = remote_cwd.join(relative_target_path); + let local_write_root = local_cwd.path().join(relative_write_root); + let local_target_path = local_cwd.path().join(relative_target_path); + fs::create_dir(&local_write_root)?; + test.fs() + .create_directory( + &remote_write_root, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + + let expected_permissions = RequestPermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![remote_write_root.clone()]), + )), + ..RequestPermissionProfile::default() + }; + let approved_response = RequestPermissionsResponse { + permissions: expected_permissions.clone(), + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }; + let command = format!( + "printf 'remote-request-permissions-ok' > {relative_target_path} && cat {relative_target_path}" + ); + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-request-permissions-remote-1"), + ev_function_call( + "permissions-call", + "request_permissions", + &json!({ + "environment_id": REMOTE_ENVIRONMENT_ID, + "reason": "Allow writing inside the selected remote environment", + "permissions": { + "file_system": { + "write": [relative_write_root], + }, + }, + }) + .to_string(), + ), + ev_completed("resp-request-permissions-remote-1"), + ]), + sse(vec![ + ev_response_created("resp-request-permissions-remote-2"), + ev_function_call( + "exec-call", + "exec_command", + &json!({ + "shell": "/bin/sh", + "cmd": command, + "login": false, + "yield_time_ms": 1_000, + "environment_id": REMOTE_ENVIRONMENT_ID, + }) + .to_string(), + ), + ev_completed("resp-request-permissions-remote-2"), + ]), + sse(vec![ + ev_response_created("resp-request-permissions-remote-3"), + ev_assistant_message("msg-request-permissions-remote-1", "done"), + ev_completed("resp-request-permissions-remote-3"), + ]), + ], + ) + .await; + + submit_turn_with_approval_and_environments( + &test, + "request permissions, then write in the remote environment", + vec![ + TurnEnvironmentSelection { + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), + cwd: local_cwd.path().abs(), + }, + TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: remote_cwd.clone(), + }, + ], + ) + .await?; + + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + let EventMsg::RequestPermissions(request) = event else { + panic!("expected remote request_permissions before completion: {event:?}"); + }; + assert_eq!(request.call_id, "permissions-call"); + assert_eq!( + request.environment_id.as_deref(), + Some(REMOTE_ENVIRONMENT_ID) + ); + assert_eq!(request.cwd.as_ref(), Some(&remote_cwd)); + assert_eq!(request.permissions, expected_permissions); + + test.codex + .submit(Op::RequestPermissionsResponse { + id: "permissions-call".to_string(), + response: approved_response.clone(), + }) + .await?; + + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + match event { + EventMsg::TurnComplete(_) => {} + EventMsg::ExecApprovalRequest(approval) => { + panic!("remote request_permissions grant should preapprove exec: {approval:?}"); + } + other => panic!("unexpected event: {other:?}"), + } + + let permissions_output: RequestPermissionsResponse = serde_json::from_str( + &response_mock + .function_call_output_text("permissions-call") + .expect("expected request_permissions output"), + )?; + assert_eq!(permissions_output, approved_response); + let exec_output = response_mock + .function_call_output_text("exec-call") + .expect("expected exec output"); + assert!( + exec_output.contains("remote-request-permissions-ok"), + "unexpected exec output: {exec_output}", + ); + assert_eq!( + test.fs() + .read_file_text(&remote_target_path, /*sandbox*/ None) + .await?, + "remote-request-permissions-ok" + ); + assert!( + !local_target_path.exists(), + "remote exec should not write through the local environment" + ); + + test.fs() + .remove( + &remote_cwd, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result<()> { skip_if_no_network!(Ok(()));