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/archive-release-symbols-and-strip-binaries.sh b/.github/scripts/archive-release-symbols-and-strip-binaries.sh deleted file mode 100755 index 3e5894bb99..0000000000 --- a/.github/scripts/archive-release-symbols-and-strip-binaries.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: archive-release-symbols-and-strip-binaries.sh \ - --target \ - --artifact-name \ - --release-dir \ - --archive-dir \ - --binaries "" -EOF -} - -target="" -artifact_name="" -release_dir="" -archive_dir="" -binaries="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --target) - target="${2:?--target requires a value}" - shift 2 - ;; - --artifact-name) - artifact_name="${2:?--artifact-name requires a value}" - shift 2 - ;; - --release-dir) - release_dir="${2:?--release-dir requires a value}" - shift 2 - ;; - --archive-dir) - archive_dir="${2:?--archive-dir requires a value}" - shift 2 - ;; - --binaries) - binaries="${2:?--binaries requires a value}" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unexpected argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -if [[ -z "$target" || -z "$artifact_name" || -z "$release_dir" || -z "$archive_dir" || -z "$binaries" ]]; then - usage >&2 - exit 1 -fi - -symbols_root="${RUNNER_TEMP:-/tmp}/codex-symbols-${artifact_name}" -symbols_dir="${symbols_root}/codex-symbols-${artifact_name}" -archive_path="${archive_dir%/}/codex-symbols-${artifact_name}.tar.gz" -rm -rf "$symbols_root" -mkdir -p "$symbols_dir" "$archive_dir" -read -r -a binary_names <<< "$binaries" - -case "$target" in - *apple-darwin) - for binary in "${binary_names[@]}"; do - binary_path="${release_dir%/}/${binary}" - dsym_path="${binary_path}.dSYM" - if [[ ! -f "$binary_path" ]]; then - echo "Binary $binary_path not found" >&2 - exit 1 - fi - if [[ ! -d "$dsym_path" ]]; then - echo "dSYM $dsym_path not found" >&2 - exit 1 - fi - - cp -RL "$dsym_path" "${symbols_dir}/${binary}.dSYM" - strip -S -x "$binary_path" - done - ;; - *linux*) - objcopy_bin="${OBJCOPY:-objcopy}" - strip_bin="${STRIP:-strip}" - for binary in "${binary_names[@]}"; do - binary_path="${release_dir%/}/${binary}" - debug_path="${symbols_dir}/${binary}.debug" - if [[ ! -f "$binary_path" ]]; then - echo "Binary $binary_path not found" >&2 - exit 1 - fi - - "$objcopy_bin" --only-keep-debug "$binary_path" "$debug_path" - "$strip_bin" --strip-debug --strip-unneeded "$binary_path" - "$objcopy_bin" --add-gnu-debuglink="$debug_path" "$binary_path" - done - ;; - *windows*) - for binary in "${binary_names[@]}"; do - pdb_path="${release_dir%/}/${binary}.pdb" - if [[ ! -f "$pdb_path" ]]; then - echo "PDB $pdb_path not found" >&2 - exit 1 - fi - - cp "$pdb_path" "${symbols_dir}/${binary}.pdb" - done - ;; - *) - echo "No symbols packaging support for target: $target" >&2 - exit 1 - ;; -esac - -rm -f "$archive_path" -tar -C "$symbols_root" -czf "$archive_path" "codex-symbols-${artifact_name}" 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..f9f329d686 --- /dev/null +++ b/.github/scripts/run_bazel_with_buildbuddy.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 + +import json +import os +import subprocess +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:]) + if os.name == "nt": + # Windows CRT exec can split arguments containing spaces and lose the + # eventual child exit status. Wait for Bazel and propagate its status. + result = subprocess.run(command, check=False) + raise SystemExit(result.returncode) + + 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..0f594b7947 --- /dev/null +++ b/.github/scripts/test_run_bazel_with_buildbuddy.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 + +import json +import os +import subprocess +import sys +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"], + ) + + def test_main_preserves_spaced_argument_and_child_exit_status(self) -> None: + spaced_arg = ( + r"--test_env=PATH=C:\Program Files\PowerShell\7;C:\Program Files\Git\bin" + ) + child_code = ( + f"import sys; sys.exit(37 if sys.argv[1] == {spaced_arg!r} else 91)" + ) + env = os.environ.copy() + env["CODEX_BAZEL_BIN"] = sys.executable + env.pop("BUILDBUDDY_API_KEY", None) + + result = subprocess.run( + [ + sys.executable, + str(Path(run_bazel_with_buildbuddy.__file__)), + "-c", + child_code, + spaced_arg, + ], + env=env, + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 37, result.stderr) + + +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..c6dfd60231 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 @@ -152,6 +158,11 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - name: Test BuildBuddy Bazel wrapper + if: matrix.shard == 1 + shell: pwsh + run: python .github/scripts/test_run_bazel_with_buildbuddy.py + - name: Prepare Bazel CI id: prepare_bazel uses: ./.github/actions/prepare-bazel-ci diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index 656c50ef28..bbadb57f94 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -6,6 +6,11 @@ on: branches: - main +# Cargo's libgit2 transport has been flaky when fetching git dependencies with +# nested submodules. Prefer the system git CLI across every Cargo invocation. +env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + jobs: cargo-deny: runs-on: ubuntu-latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9adc90e040..63c6ffe520 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,13 +76,13 @@ jobs: - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: - tool: just + tool: just@1.51.0 - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: version: "0.11.3" - - name: Ruff format Python scripts (run `just fmt` to fix) - run: just fmt-scripts-check + - name: Check formatting (run `just fmt` to fix) + run: just fmt-check - name: Prettier (run `pnpm run format:fix` to fix) run: pnpm run format diff --git a/.github/workflows/python-sdk-release.yml b/.github/workflows/python-sdk-release.yml index 654dd8d822..2db8b4b53e 100644 --- a/.github/workflows/python-sdk-release.yml +++ b/.github/workflows/python-sdk-release.yml @@ -4,15 +4,161 @@ on: push: tags: - "python-v*" + workflow_dispatch: + inputs: + runtime_version: + description: "Runtime version to publish before updating the SDK pin, for example 0.136.0 or 0.136.0a2." + required: true + type: string concurrency: group: ${{ github.workflow }} cancel-in-progress: false jobs: - build-python-sdk: + # Publish the platform-specific Python runtime wheels before building the SDK + # package that pins them, or explicitly before updating the SDK runtime pin. + # PyPI project configuration must trust this workflow and job for publishing. + publish-python-runtime: if: github.repository == 'openai/codex' + name: publish-python-runtime + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write # Required for PyPI trusted publishing. + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Validate SDK tag and resolve Python runtime release + id: python_runtime + shell: bash + env: + REQUESTED_RUNTIME_VERSION: ${{ inputs.runtime_version }} + run: | + set -euo pipefail + python3 - <<'PY' + import os + import re + import tomllib + from pathlib import Path + + event_name = os.environ["GITHUB_EVENT_NAME"] + if event_name == "workflow_dispatch": + python_version = os.environ["REQUESTED_RUNTIME_VERSION"] + elif event_name == "push": + sdk_version = os.environ["GITHUB_REF_NAME"].removeprefix("python-v") + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+b[0-9]+", sdk_version): + raise SystemExit( + "Python SDK release tags must identify a beta release, " + "for example python-v0.1.0b1." + ) + + pyproject = tomllib.loads(Path("sdk/python/pyproject.toml").read_text()) + prefix = "openai-codex-cli-bin==" + versions = [ + dependency.removeprefix(prefix) + for dependency in pyproject["project"]["dependencies"] + if dependency.startswith(prefix) + ] + if len(versions) != 1: + raise SystemExit(f"Expected exactly one pinned {prefix} dependency, found {versions}") + python_version = versions[0] + else: + raise SystemExit(f"Unsupported workflow event: {event_name}") + + if match := re.fullmatch(r"([0-9]+\.[0-9]+\.[0-9]+)a([0-9]+)", python_version): + release_version = f"{match.group(1)}-alpha.{match.group(2)}" + elif re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", python_version): + release_version = python_version + else: + raise SystemExit( + "Python runtime version must be stable or a numbered alpha, " + f"for example 0.136.0 or 0.136.0a2; found {python_version}" + ) + + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: + print(f"python_version={python_version}", file=output) + print(f"release_tag=rust-v{release_version}", file=output) + PY + + - name: Download Python runtime release artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PYTHON_RUNTIME_VERSION: ${{ steps.python_runtime.outputs.python_version }} + RELEASE_TAG: ${{ steps.python_runtime.outputs.release_tag }} + run: | + set -euo pipefail + mkdir -p dist/python-runtime dist/python-runtime-packages + gh release download "$RELEASE_TAG" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "openai_codex_cli_bin-${PYTHON_RUNTIME_VERSION}-*.whl" \ + --dir dist/python-runtime + gh release download "$RELEASE_TAG" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-package-*-unknown-linux-musl.tar.gz" \ + --dir dist/python-runtime-packages + + shopt -s nullglob + wheels=(dist/python-runtime/*.whl) + if [[ "${#wheels[@]}" -ne 6 ]]; then + echo "Expected 6 Python runtime wheels for ${PYTHON_RUNTIME_VERSION}, found ${#wheels[@]}." + exit 1 + fi + packages=(dist/python-runtime-packages/*.tar.gz) + if [[ "${#packages[@]}" -ne 2 ]]; then + echo "Expected 2 Linux package archives for ${PYTHON_RUNTIME_VERSION}, found ${#packages[@]}." + exit 1 + fi + + - name: Build musllinux Python runtime wheels + env: + RELEASE_TAG: ${{ steps.python_runtime.outputs.release_tag }} + run: | + set -euo pipefail + + python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build + + while read -r target platform_tag; do + stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${target}-${platform_tag}" + python3 sdk/python/scripts/update_sdk_artifacts.py \ + stage-runtime \ + "$stage_dir" \ + "dist/python-runtime-packages/codex-package-${target}.tar.gz" \ + --codex-version "$RELEASE_TAG" \ + --platform-tag "$platform_tag" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build \ + --wheel \ + --outdir dist/python-runtime \ + "$stage_dir" + done <<'EOF' + aarch64-unknown-linux-musl musllinux_1_1_aarch64 + x86_64-unknown-linux-musl musllinux_1_1_x86_64 + EOF + + shopt -s nullglob + wheels=(dist/python-runtime/*.whl) + if [[ "${#wheels[@]}" -ne 8 ]]; then + echo "Expected 8 Python runtime wheels, found ${#wheels[@]}." + exit 1 + fi + ls -lh dist/python-runtime + + - name: Publish Python runtime wheels to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + packages-dir: dist/python-runtime + skip-existing: true + + build-python-sdk: + if: github.event_name == 'push' && github.repository == 'openai/codex' name: build-python-sdk + needs: publish-python-runtime runs-on: ubuntu-latest permissions: contents: read @@ -29,13 +175,8 @@ jobs: set -euo pipefail sdk_version="${GITHUB_REF_NAME#python-v}" - if [[ ! "${sdk_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+b[0-9]+$ ]]; then - echo "Python SDK release tags must identify a beta release, for example python-v0.1.0b1." - 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 +187,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/rust-ci.yml b/.github/workflows/rust-ci.yml index d1d7c0df96..9b50ae4032 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -3,6 +3,11 @@ on: pull_request: {} workflow_dispatch: +# Cargo's libgit2 transport has been flaky when fetching git dependencies with +# nested submodules. Prefer the system git CLI across every Cargo invocation. +env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + jobs: # --- Detect what changed so the fast PR workflow only runs relevant jobs ---- changed: diff --git a/.github/workflows/rust-release-argument-comment-lint.yml b/.github/workflows/rust-release-argument-comment-lint.yml index 94f15af7d4..7f5ad01aa5 100644 --- a/.github/workflows/rust-release-argument-comment-lint.yml +++ b/.github/workflows/rust-release-argument-comment-lint.yml @@ -7,6 +7,11 @@ on: required: true type: boolean +# Cargo's libgit2 transport has been flaky when fetching git dependencies with +# nested submodules. Prefer the system git CLI across every Cargo invocation. +env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + jobs: skip: if: ${{ !inputs.publish }} diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 91887cbe0b..89bcd33ea1 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -20,6 +20,11 @@ on: 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. +env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + jobs: build-windows-binaries: name: Build Windows binaries - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} @@ -34,8 +39,6 @@ jobs: working-directory: codex-rs env: CARGO_PROFILE_RELEASE_LTO: ${{ inputs.release-lto }} - CARGO_PROFILE_RELEASE_DEBUG: full - CARGO_PROFILE_RELEASE_STRIP: "false" strategy: fail-fast: false @@ -133,7 +136,6 @@ jobs: 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" done - name: Upload Windows binaries @@ -216,23 +218,6 @@ jobs: account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - - name: Build symbols archive - shell: bash - run: | - bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \ - --target "${{ matrix.target }}" \ - --artifact-name "${{ matrix.target }}" \ - --release-dir "target/${{ matrix.target }}/release" \ - --archive-dir "symbols-dist/${{ matrix.target }}" \ - --binaries "${WINDOWS_BINARIES}" - - - name: Upload symbols archive - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.target }}-symbols - path: codex-rs/symbols-dist/${{ matrix.target }}/* - if-no-files-found: error - - name: Stage artifacts shell: bash run: | diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 364fe78902..55f95fa270 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -149,9 +149,6 @@ jobs: # 2026-03-04: temporarily change releases to use thin LTO because # Ubuntu ARM is timing out at 60 minutes. CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - CARGO_PROFILE_RELEASE_DEBUG: full - CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO: ${{ contains(matrix.target, 'apple-darwin') && 'packed' || 'off' }} - CARGO_PROFILE_RELEASE_STRIP: "false" # Use the git CLI instead of Cargo's libgit2 path for git dependencies. # macOS release runners have intermittently failed to fetch nested # submodules through SecureTransport/libgit2, especially libwebrtc's @@ -252,7 +249,7 @@ jobs: run: | set -euo pipefail sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends binutils pkg-config libcap-dev + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ matrix.target }} @@ -311,10 +308,6 @@ jobs: exit 1 fi - # Codex embeds this digest at build time and verifies the bundled - # bwrap resource before use. Strip bwrap before hashing so the digest - # covers the exact bytes that the release packages. - strip --strip-debug --strip-unneeded "$bwrap_path" digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" echo "Built bwrap ${bwrap_path} with sha256:${digest}" @@ -328,11 +321,6 @@ jobs: fi build_args=() for binary in ${{ matrix.binaries }}; do - # bwrap was built, finalized, and hashed before this build so - # Codex can embed the digest of the bytes that will be packaged. - if [[ "$binary" == "bwrap" ]]; then - continue - fi build_args+=(--bin "$binary") done echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" @@ -345,32 +333,6 @@ jobs: path: codex-rs/target/**/cargo-timings/cargo-timing.html if-no-files-found: warn - - name: Build symbols archive and strip binaries - shell: bash - run: | - binaries=() - for binary in ${{ matrix.binaries }}; do - # bwrap is already stripped before hashing. Its symbols are not - # useful enough to justify a separate pre-Codex symbols pass. - if [[ "$binary" == "bwrap" ]]; then - continue - fi - binaries+=("$binary") - done - bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \ - --target "${{ matrix.target }}" \ - --artifact-name "${{ matrix.artifact_name }}" \ - --release-dir "target/${{ matrix.target }}/release" \ - --archive-dir "symbols-dist/${{ matrix.artifact_name }}" \ - --binaries "${binaries[*]}" - - - name: Upload symbols archive - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: ${{ matrix.artifact_name }}-symbols - path: codex-rs/symbols-dist/${{ matrix.artifact_name }}/* - if-no-files-found: error - - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} name: Stage unsigned macOS artifacts shell: bash @@ -929,7 +891,6 @@ jobs: sign_macos: ${{ steps.release_mode.outputs.sign_macos }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} - should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }} steps: - name: Checkout repository @@ -1069,7 +1030,6 @@ jobs: run: | find dist -mindepth 1 -maxdepth 1 -type d \ ! -name '*-apple-darwin*-unsigned' \ - ! -name '*-symbols' \ ! -name 'aarch64-unknown-linux-musl' \ ! -name 'aarch64-unknown-linux-musl-app-server' \ ! -name 'x86_64-unknown-linux-musl' \ @@ -1154,27 +1114,6 @@ jobs: echo "npm_tag=" >> "$GITHUB_OUTPUT" fi - - name: Determine Python runtime publish settings - id: python_runtime_publish_settings - env: - VERSION: ${{ steps.release_name.outputs.name }} - run: | - set -euo pipefail - version="${VERSION}" - - if [[ "${SIGN_MACOS}" != "true" ]]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - else - echo "should_publish=false" >> "$GITHUB_OUTPUT" - fi - - name: Setup pnpm if: ${{ env.SIGN_MACOS == 'true' }} uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 @@ -1439,53 +1378,6 @@ jobs: exit "${publish_status}" done - # Publish the platform-specific Python runtime wheels using PyPI trusted publishing. - # PyPI project configuration must trust this workflow and job. Keep this - # non-blocking while the Python runtime publishing path is new; failures still - # need release follow-up, but should not invalidate the Rust release itself. - publish-python-runtime: - # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. - if: >- - ${{ - !cancelled() && - needs.release.result == 'success' && - needs.release.outputs.should_publish_python_runtime == 'true' - }} - name: publish-python-runtime - needs: release - runs-on: ubuntu-latest - continue-on-error: true - environment: pypi - permissions: - id-token: write # Required for PyPI trusted publishing. - contents: read - - steps: - - name: Download Python runtime wheels from release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} - run: | - set -euo pipefail - python_version="$RELEASE_VERSION" - python_version="${python_version/-alpha./a}" - python_version="${python_version/-beta./b}" - python_version="${python_version/-rc./rc}" - - mkdir -p dist/python-runtime - gh release download "$RELEASE_TAG" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "openai_codex_cli_bin-${python_version}-*.whl" \ - --dir dist/python-runtime - ls -lh dist/python-runtime - - - name: Publish Python runtime wheels to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 - with: - packages-dir: dist/python-runtime - skip-existing: true - deploy-dev-website: name: Trigger developers.openai.com deploy needs: release diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index d98cdb2c43..d6fb73e96d 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -5,6 +5,11 @@ on: tags: - "rusty-v8-v*.*.*" +# Cargo's libgit2 transport has been flaky when fetching git dependencies with +# nested submodules. Prefer the system git CLI for Cargo smoke tests. +env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + concurrency: group: ${{ github.workflow }}::${{ github.ref_name }} cancel-in-progress: false @@ -186,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/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/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 6341f218a6..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" @@ -37,6 +39,11 @@ on: - "third_party/v8/**" workflow_dispatch: +# Cargo's libgit2 transport has been flaky when fetching git dependencies with +# nested submodules. Prefer the system git CLI for Cargo builds and smoke tests. +env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + concurrency: group: ${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }} cancel-in-progress: ${{ github.ref_name != 'main' }} @@ -198,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/AGENTS.md b/AGENTS.md index 63a9ba67c4..88d3fc4eea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,49 @@ Particularly when introducing a new concept/feature/API, before adding to `codex Likewise, when reviewing code, do not hesitate to push back on PRs that would unnecessarily add code to `codex-core`. +## Code Review Rules + +### Model visible context + +Codex maintains a context (history of messages) that is sent to the model in inference requests. + +1. No history rewrite - the context must be built up incrementally. +2. Avoid frequent changes to context that cause cache misses. +3. No unbounded items - everything injected in the model context must have a bounded size and a hard cap. +4. No items larger than 10K tokens. +5. Highlight new individual items that can cross >1k tokens as P0. These need an additional manual review. +6. All injected fragments must be defined as structs in `core/context` and implement ContextualUserFragment trait + +### Breaking changes + +Search for breaking changes in external integration surfaces: + +- app-server APIs +- CLI parameters +- configuration loading +- resuming sessions from existing rollouts + +### Test authoring guidance + +For agent changes prefer integration tests over unit tests. Integration tests are under `core/suite` and use `test_codex` to set up a test instance of codex. + +Features that change the agent logic MUST add an integration test: + +- Provide a list of major logic changes and user-facing behaviors that need to be tested. + +If unit tests are needed, put them in a dedicated test file (\*\_tests.rs). +Avoid test-only functions in the main implementation. + +Check whether there are existing helpers to make tests more streamlined and readable. + +### Change size guidance (800 lines) + +Unless the change is mechanical the total number of changed lines should not exceed 800 lines. +For complex logic changes the size should be under 500 lines. + +If the change is larger, explore whether it can be split into reviewable stages and identify the smallest coherent stage to land first. +Base the staging suggestion on the actual diff, dependencies, and affected call sites. + ## TUI style conventions See `codex-rs/tui/styles.md`. diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index fd90b5ec21..a70ff1c483 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", @@ -2356,7 +2355,6 @@ dependencies = [ "codex-login", "codex-otel", "codex-protocol", - "codex-utils-absolute-path", "hmac 0.12.1", "pretty_assertions", "serde", @@ -2365,7 +2363,6 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "toml 0.9.11+spec-1.1.0", "tracing", ] @@ -3367,6 +3364,7 @@ dependencies = [ "codex-utils-home-dir", "codex-utils-rustls-provider", "globset", + "libc", "pretty_assertions", "rama-core", "rama-http", @@ -3377,6 +3375,8 @@ dependencies = [ "rama-tls-rustls", "rama-unix", "rustls-native-certs", + "schannel", + "security-framework 3.5.1", "serde", "serde_json", "sha2 0.10.9", @@ -3386,6 +3386,7 @@ dependencies = [ "tokio", "tracing", "url", + "windows-sys 0.52.0", ] [[package]] @@ -3719,6 +3720,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "codex-skills-extension" +version = "0.0.0" +dependencies = [ + "async-trait", + "codex-core", + "codex-extension-api", +] + [[package]] name = "codex-state" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index cc20f8a3a8..482951fb92 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -48,6 +48,7 @@ members = [ "ext/guardian", "ext/image-generation", "ext/memories", + "ext/skills", "ext/web-search", "external-agent-migration", "external-agent-sessions", diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index b3f6ba22ce..7ceda36a6a 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; @@ -389,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, } } @@ -833,6 +836,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()), @@ -3252,10 +3256,14 @@ 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), 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, @@ -3314,10 +3322,14 @@ 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", "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, @@ -3631,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)); @@ -3983,6 +3996,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 +4024,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 +4068,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..61fe75ac27 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; @@ -793,10 +794,14 @@ 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, 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..b056108507 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, @@ -81,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, } @@ -99,6 +104,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 +475,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..5b41178530 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(), @@ -2448,9 +2483,11 @@ 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(); + let codex_error = turn_state.codex_error.as_ref(); CodexTurnEventParams { thread_id, session_id: thread_metadata.session_id.clone(), @@ -2479,10 +2516,14 @@ 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, 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/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-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/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 4eb5cae387..faf5a96879 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -29,6 +29,7 @@ "type": "object" }, "AccountRateLimitsUpdatedNotification": { + "description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.", "properties": { "rateLimits": { "$ref": "#/definitions/RateLimitSnapshot" @@ -2677,6 +2678,16 @@ } ] }, + "individualLimit": { + "anyOf": [ + { + "$ref": "#/definitions/SpendControlLimitSnapshot" + }, + { + "type": "null" + } + ] + }, "limitId": { "type": [ "string", @@ -3135,6 +3146,31 @@ "description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.", "type": "object" }, + "SpendControlLimitSnapshot": { + "properties": { + "limit": { + "type": "string" + }, + "remainingPercent": { + "format": "int32", + "type": "integer" + }, + "resetsAt": { + "format": "int64", + "type": "integer" + }, + "used": { + "type": "string" + } + }, + "required": [ + "limit", + "remainingPercent", + "resetsAt", + "used" + ], + "type": "object" + }, "SubAgentSource": { "oneOf": [ { 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 b97c1f8fb7..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" }, @@ -5720,6 +5727,7 @@ }, "AccountRateLimitsUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.", "properties": { "rateLimits": { "$ref": "#/definitions/v2/RateLimitSnapshot" @@ -5951,6 +5959,16 @@ }, "AppConfig": { "properties": { + "approvals_reviewer": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ApprovalsReviewer" + }, + { + "type": "null" + } + ] + }, "default_tools_approval_mode": { "anyOf": [ { @@ -13327,6 +13345,16 @@ } ] }, + "individualLimit": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SpendControlLimitSnapshot" + }, + { + "type": "null" + } + ] + }, "limitId": { "type": [ "string", @@ -15269,6 +15297,31 @@ ], "type": "string" }, + "SpendControlLimitSnapshot": { + "properties": { + "limit": { + "type": "string" + }, + "remainingPercent": { + "format": "int32", + "type": "integer" + }, + "resetsAt": { + "format": "int64", + "type": "integer" + }, + "used": { + "type": "string" + } + }, + "required": [ + "limit", + "remainingPercent", + "resetsAt", + "used" + ], + "type": "object" + }, "SubAgentSource": { "oneOf": [ { @@ -19186,4 +19239,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f8261c86d6..6d859ee5f3 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -92,6 +92,7 @@ }, "AccountRateLimitsUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.", "properties": { "rateLimits": { "$ref": "#/definitions/RateLimitSnapshot" @@ -323,6 +324,16 @@ }, "AppConfig": { "properties": { + "approvals_reviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ] + }, "default_tools_approval_mode": { "anyOf": [ { @@ -9856,6 +9867,16 @@ } ] }, + "individualLimit": { + "anyOf": [ + { + "$ref": "#/definitions/SpendControlLimitSnapshot" + }, + { + "type": "null" + } + ] + }, "limitId": { "type": [ "string", @@ -13093,6 +13114,31 @@ ], "type": "string" }, + "SpendControlLimitSnapshot": { + "properties": { + "limit": { + "type": "string" + }, + "remainingPercent": { + "format": "int32", + "type": "integer" + }, + "resetsAt": { + "format": "int64", + "type": "integer" + }, + "used": { + "type": "string" + } + }, + "required": [ + "limit", + "remainingPercent", + "resetsAt", + "used" + ], + "type": "object" + }, "SubAgentSource": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json index 96fefb228b..c518085df9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json @@ -61,6 +61,16 @@ } ] }, + "individualLimit": { + "anyOf": [ + { + "$ref": "#/definitions/SpendControlLimitSnapshot" + }, + { + "type": "null" + } + ] + }, "limitId": { "type": [ "string", @@ -141,8 +151,34 @@ "usedPercent" ], "type": "object" + }, + "SpendControlLimitSnapshot": { + "properties": { + "limit": { + "type": "string" + }, + "remainingPercent": { + "format": "int32", + "type": "integer" + }, + "resetsAt": { + "format": "int64", + "type": "integer" + }, + "used": { + "type": "string" + } + }, + "required": [ + "limit", + "remainingPercent", + "resetsAt", + "used" + ], + "type": "object" } }, + "description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.", "properties": { "rateLimits": { "$ref": "#/definitions/RateLimitSnapshot" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index e714c0975d..09cf897898 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -19,6 +19,16 @@ }, "AppConfig": { "properties": { + "approvals_reviewer": { + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ] + }, "default_tools_approval_mode": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json index 8f7db24e7f..7916d619ec 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -61,6 +61,16 @@ } ] }, + "individualLimit": { + "anyOf": [ + { + "$ref": "#/definitions/SpendControlLimitSnapshot" + }, + { + "type": "null" + } + ] + }, "limitId": { "type": [ "string", @@ -141,6 +151,31 @@ "usedPercent" ], "type": "object" + }, + "SpendControlLimitSnapshot": { + "properties": { + "limit": { + "type": "string" + }, + "remainingPercent": { + "format": "int32", + "type": "integer" + }, + "resetsAt": { + "format": "int64", + "type": "integer" + }, + "used": { + "type": "string" + } + }, + "required": [ + "limit", + "remainingPercent", + "resetsAt", + "used" + ], + "type": "object" } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AccountRateLimitsUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AccountRateLimitsUpdatedNotification.ts index 96c735a2eb..a6d6a33f49 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AccountRateLimitsUpdatedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AccountRateLimitsUpdatedNotification.ts @@ -3,4 +3,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { RateLimitSnapshot } from "./RateLimitSnapshot"; +/** + * Sparse rolling rate-limit update. + * + * Clients should merge available values into the most recent `account/rateLimits/read` response + * or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and + * does not clear a previously observed value. + */ export type AccountRateLimitsUpdatedNotification = { rateLimits: RateLimitSnapshot, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts index b22997de97..a4a0f22081 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts @@ -3,6 +3,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AppToolApproval } from "./AppToolApproval"; import type { AppToolsConfig } from "./AppToolsConfig"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AppsDefaultConfig } from "./AppsDefaultConfig"; -export type AppsConfig = { _default: AppsDefaultConfig | null, } & ({ [key in string]?: { enabled: boolean, destructive_enabled: boolean | null, open_world_enabled: boolean | null, default_tools_approval_mode: AppToolApproval | null, default_tools_enabled: boolean | null, tools: AppToolsConfig | null, } }); +export type AppsConfig = { _default: AppsDefaultConfig | null, } & ({ [key in string]?: { enabled: boolean, approvals_reviewer: ApprovalsReviewer | null, destructive_enabled: boolean | null, open_world_enabled: boolean | null, default_tools_approval_mode: AppToolApproval | null, default_tools_enabled: boolean | null, tools: AppToolsConfig | null, } }); 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/schema/typescript/v2/RateLimitSnapshot.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts index dc8417a304..c1e3953dfc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts @@ -5,5 +5,6 @@ import type { PlanType } from "../PlanType"; import type { CreditsSnapshot } from "./CreditsSnapshot"; import type { RateLimitReachedType } from "./RateLimitReachedType"; import type { RateLimitWindow } from "./RateLimitWindow"; +import type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot"; -export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, }; +export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, individualLimit: SpendControlLimitSnapshot | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SpendControlLimitSnapshot.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SpendControlLimitSnapshot.ts new file mode 100644 index 0000000000..077e590af0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SpendControlLimitSnapshot.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SpendControlLimitSnapshot = { limit: string, used: string, remainingPercent: number, resetsAt: number, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 66b67fb1b4..48e1f9580b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -350,6 +350,7 @@ export type { SkillsListEntry } from "./SkillsListEntry"; export type { SkillsListParams } from "./SkillsListParams"; export type { SkillsListResponse } from "./SkillsListResponse"; export type { SortDirection } from "./SortDirection"; +export type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot"; export type { SubagentMigration } from "./SubagentMigration"; export type { TerminalInteractionNotification } from "./TerminalInteractionNotification"; export type { TextElement } from "./TextElement"; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 56cf7038f2..20f923f327 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -39,6 +39,8 @@ use ts_rs::TS; pub(crate) const GENERATED_TS_HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"]; const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"]; +const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = + &["RemoteControlClient", "RemoteControlClientsListOrder"]; const SPECIAL_DEFINITIONS: &[&str] = &[ "ClientNotification", "ClientRequest", @@ -554,6 +556,7 @@ fn experimental_method_types() -> HashSet { let mut type_names = HashSet::new(); collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_PARAM_TYPES, &mut type_names); collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_RESPONSE_TYPES, &mut type_names); + collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES, &mut type_names); type_names } @@ -2132,6 +2135,14 @@ mod tests { fixture_tree.contains_key(Path::new("v2/MockExperimentalMethodResponse.ts")), false ); + assert_eq!( + fixture_tree.contains_key(Path::new("v2/RemoteControlClient.ts")), + false + ); + assert_eq!( + fixture_tree.contains_key(Path::new("v2/RemoteControlClientsListOrder.ts")), + false + ); let mut undefined_offenders = Vec::new(); let mut optional_nullable_offenders = BTreeSet::new(); @@ -2847,6 +2858,11 @@ permissionProfile?: string | null}; flat_v2_bundle_json.contains("MockExperimentalMethodResponse"), false ); + assert_eq!(flat_v2_bundle_json.contains("RemoteControlClient"), false); + assert_eq!( + flat_v2_bundle_json.contains("RemoteControlClientsListOrder"), + false + ); assert_eq!(flat_v2_bundle_json.contains("#/definitions/v2/"), false); assert_eq!( flat_v2_bundle_json.contains("\"title\": \"CodexAppServerProtocolV2\""), @@ -2920,6 +2936,45 @@ permissionProfile?: string | null}; .exists(), false ); + assert_eq!( + output_dir + .join("v2") + .join("RemoteControlClient.json") + .exists(), + false + ); + assert_eq!( + output_dir + .join("v2") + .join("RemoteControlClientsListOrder.json") + .exists(), + false + ); + + let _cleanup = fs::remove_dir_all(&output_dir); + Ok(()) + } + + #[test] + fn generate_json_includes_remote_control_methods_with_experimental_api() -> Result<()> { + let output_dir = std::env::temp_dir().join(format!("codex_schema_{}", Uuid::now_v7())); + fs::create_dir(&output_dir)?; + generate_json_with_experimental(&output_dir, /*experimental_api*/ true)?; + + let client_request_json = fs::read_to_string(output_dir.join("ClientRequest.json"))?; + assert!(client_request_json.contains("remoteControl/pairing/start")); + assert!(client_request_json.contains("remoteControl/client/list")); + assert!(client_request_json.contains("remoteControl/client/revoke")); + for schema in [ + "RemoteControlPairingStartParams.json", + "RemoteControlPairingStartResponse.json", + "RemoteControlClientsListParams.json", + "RemoteControlClientsListResponse.json", + "RemoteControlClientsRevokeParams.json", + "RemoteControlClientsRevokeResponse.json", + ] { + assert!(output_dir.join("v2").join(schema).exists()); + } let _cleanup = fs::remove_dir_all(&output_dir); Ok(()) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index d532952f6c..5a60d8d002 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -843,6 +843,24 @@ client_request_definitions! { serialization: global_shared_read("remote-control"), response: v2::RemoteControlStatusReadResponse, }, + #[experimental("remoteControl/pairing/start")] + RemoteControlPairingStart => "remoteControl/pairing/start" { + params: v2::RemoteControlPairingStartParams, + serialization: global("remote-control-pairing"), + response: v2::RemoteControlPairingStartResponse, + }, + #[experimental("remoteControl/client/list")] + RemoteControlClientsList => "remoteControl/client/list" { + params: v2::RemoteControlClientsListParams, + serialization: global_shared_read("remote-control-clients"), + response: v2::RemoteControlClientsListResponse, + }, + #[experimental("remoteControl/client/revoke")] + RemoteControlClientsRevoke => "remoteControl/client/revoke" { + params: v2::RemoteControlClientsRevokeParams, + serialization: global("remote-control-clients"), + response: v2::RemoteControlClientsRevokeResponse, + }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. CollaborationModeList => "collaborationMode/list" { @@ -1977,6 +1995,40 @@ mod tests { }, }; assert_eq!(mcp_resource_read.serialization_scope(), None); + + let remote_control_pairing_start = ClientRequest::RemoteControlPairingStart { + request_id: request_id(), + params: v2::RemoteControlPairingStartParams::default(), + }; + assert_eq!( + remote_control_pairing_start.serialization_scope(), + Some(ClientRequestSerializationScope::Global( + "remote-control-pairing" + )) + ); + let remote_control_clients_list = ClientRequest::RemoteControlClientsList { + request_id: request_id(), + params: v2::RemoteControlClientsListParams::default(), + }; + assert_eq!( + remote_control_clients_list.serialization_scope(), + Some(ClientRequestSerializationScope::GlobalSharedRead( + "remote-control-clients" + )) + ); + let remote_control_clients_revoke = ClientRequest::RemoteControlClientsRevoke { + request_id: request_id(), + params: v2::RemoteControlClientsRevokeParams { + environment_id: "environment-id".to_string(), + client_id: "client-id".to_string(), + }, + }; + assert_eq!( + remote_control_clients_revoke.serialization_scope(), + Some(ClientRequestSerializationScope::Global( + "remote-control-clients" + )) + ); } #[test] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 796c93d555..c2a538982c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -6,6 +6,7 @@ use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; +use codex_protocol::protocol::SpendControlLimitSnapshot as CoreSpendControlLimitSnapshot; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -247,6 +248,11 @@ pub struct AccountUpdatedNotification { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] +/// Sparse rolling rate-limit update. +/// +/// Clients should merge available values into the most recent `account/rateLimits/read` response +/// or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and +/// does not clear a previously observed value. pub struct AccountRateLimitsUpdatedNotification { pub rate_limits: RateLimitSnapshot, } @@ -260,6 +266,7 @@ pub struct RateLimitSnapshot { pub primary: Option, pub secondary: Option, pub credits: Option, + pub individual_limit: Option, pub plan_type: Option, pub rate_limit_reached_type: Option, } @@ -272,6 +279,7 @@ impl From for RateLimitSnapshot { primary: value.primary.map(RateLimitWindow::from), secondary: value.secondary.map(RateLimitWindow::from), credits: value.credits.map(CreditsSnapshot::from), + individual_limit: value.individual_limit.map(SpendControlLimitSnapshot::from), plan_type: value.plan_type, rate_limit_reached_type: value .rate_limit_reached_type @@ -371,6 +379,28 @@ impl From for CreditsSnapshot { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SpendControlLimitSnapshot { + pub limit: String, + pub used: String, + pub remaining_percent: i32, + #[ts(type = "number")] + pub resets_at: i64, +} + +impl From for SpendControlLimitSnapshot { + fn from(value: CoreSpendControlLimitSnapshot) -> Self { + Self { + limit: value.limit, + used: value.used, + remaining_percent: value.remaining_percent, + resets_at: value.resets_at, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 473bb1e03c..f40192ddc6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -200,6 +200,7 @@ pub struct AppToolsConfig { pub struct AppConfig { #[serde(default = "default_enabled")] pub enabled: bool, + pub approvals_reviewer: Option, pub destructive_enabled: Option, pub open_world_enabled: Option, pub default_tools_approval_mode: Option, 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/remote_control.rs b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs index c8ad617e7d..f5f004f784 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs @@ -44,6 +44,80 @@ pub struct RemoteControlStatusReadResponse { pub environment_id: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlPairingStartParams { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub manual_code: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlPairingStartResponse { + pub pairing_code: String, + pub manual_pairing_code: Option, + pub environment_id: String, + pub expires_at: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlClientsListParams { + pub environment_id: String, + #[ts(optional = nullable)] + pub cursor: Option, + #[ts(optional = nullable)] + pub limit: Option, + #[ts(optional = nullable)] + pub order: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum RemoteControlClientsListOrder { + Asc, + Desc, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlClientsListResponse { + pub data: Vec, + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlClient { + pub client_id: String, + pub display_name: Option, + pub device_type: Option, + pub platform: Option, + pub os_version: Option, + pub device_model: Option, + pub app_version: Option, + pub last_seen_at: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlClientsRevokeParams { + pub environment_id: String, + pub client_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlClientsRevokeResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase", export_to = "v2/")] @@ -87,3 +161,7 @@ impl From for RemoteControlDisableRespon } } } + +#[cfg(test)] +#[path = "remote_control_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/remote_control_tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/remote_control_tests.rs new file mode 100644 index 0000000000..ff6b26d236 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control_tests.rs @@ -0,0 +1,50 @@ +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn remote_control_clients_list_params_serialize_nullable_optional_fields() { + assert_eq!( + serde_json::to_value(RemoteControlClientsListParams { + environment_id: "env-123".to_string(), + cursor: None, + limit: None, + order: None, + }) + .expect("params should serialize"), + json!({ + "environmentId": "env-123", + "cursor": null, + "limit": null, + "order": null, + }) + ); +} + +#[test] +fn remote_control_clients_list_params_deserialize_camel_case_fields() { + assert_eq!( + serde_json::from_value::(json!({ + "environmentId": "env-123", + "cursor": "cursor-123", + "limit": 10, + "order": "asc", + })) + .expect("params should deserialize"), + RemoteControlClientsListParams { + environment_id: "env-123".to_string(), + cursor: Some("cursor-123".to_string()), + limit: Some(10), + order: Some(RemoteControlClientsListOrder::Asc), + } + ); +} + +#[test] +fn remote_control_clients_revoke_response_serializes_as_empty_object() { + assert_eq!( + serde_json::to_value(RemoteControlClientsRevokeResponse {}) + .expect("response should serialize"), + json!({}) + ); +} 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-transport/src/transport/remote_control/auth.rs b/codex-rs/app-server-transport/src/transport/remote_control/auth.rs new file mode 100644 index 0000000000..dbd231027e --- /dev/null +++ b/codex-rs/app-server-transport/src/transport/remote_control/auth.rs @@ -0,0 +1,105 @@ +use codex_api::SharedAuthProvider; +use codex_login::AuthManager; +use codex_login::UnauthorizedRecovery; +use std::io; +use std::io::ErrorKind; +use std::sync::Arc; +use tokio::sync::watch; +use tracing::info; +use tracing::warn; + +pub(super) struct RemoteControlConnectionAuth { + pub(super) auth_provider: SharedAuthProvider, + pub(super) account_id: String, +} + +pub(super) async fn load_remote_control_auth( + auth_manager: &Arc, +) -> io::Result { + let mut reloaded = false; + let auth = loop { + let Some(auth) = auth_manager.auth().await else { + if reloaded { + return Err(io::Error::new( + ErrorKind::PermissionDenied, + "remote control requires ChatGPT authentication", + )); + } + auth_manager.reload().await; + reloaded = true; + continue; + }; + if !auth.uses_codex_backend() { + break auth; + } + if auth.get_account_id().is_none() && !reloaded { + auth_manager.reload().await; + reloaded = true; + continue; + } + break auth; + }; + + if !auth.uses_codex_backend() { + return Err(io::Error::new( + ErrorKind::PermissionDenied, + "remote control requires ChatGPT authentication; API key auth is not supported", + )); + } + + Ok(RemoteControlConnectionAuth { + auth_provider: codex_model_provider::auth_provider_from_auth(&auth), + account_id: auth.get_account_id().ok_or_else(|| { + io::Error::new( + ErrorKind::WouldBlock, + "remote control enrollment is waiting for a ChatGPT account id", + ) + })?, + }) +} + +pub(super) async fn recover_remote_control_auth( + auth_recovery: &mut UnauthorizedRecovery, + auth_change_rx: &mut watch::Receiver, +) -> bool { + if !auth_recovery.has_next() { + return false; + } + + let mode = auth_recovery.mode_name(); + let step = auth_recovery.step_name(); + let auth_change_revision_before_recovery = *auth_change_rx.borrow(); + match auth_recovery.next().await { + Ok(step_result) => { + if step_result.auth_state_changed() == Some(true) { + mark_recovery_auth_change_seen( + auth_change_rx, + auth_change_revision_before_recovery, + ); + } + info!( + "remote control auth recovery succeeded: mode={mode}, step={step}, auth_state_changed={:?}", + step_result.auth_state_changed() + ); + true + } + Err(err) => { + warn!("remote control auth recovery failed: mode={mode}, step={step}: {err}"); + false + } + } +} + +pub(super) fn mark_recovery_auth_change_seen( + auth_change_rx: &mut watch::Receiver, + auth_change_revision_before_recovery: u64, +) { + let auth_change_revision_after_recovery = *auth_change_rx.borrow(); + if auth_change_revision_after_recovery == auth_change_revision_before_recovery.wrapping_add(1) { + // Recovery updated the same watch that wakes the outer reconnect + // loop. Mark only that single revision seen; if more revisions + // arrived while recovery was in flight, leave them pending so the + // reconnect loop still reacts to the later external auth change. + auth_change_rx.borrow_and_update(); + } +} diff --git a/codex-rs/app-server-transport/src/transport/remote_control/clients.rs b/codex-rs/app-server-transport/src/transport/remote_control/clients.rs new file mode 100644 index 0000000000..25316c6090 --- /dev/null +++ b/codex-rs/app-server-transport/src/transport/remote_control/clients.rs @@ -0,0 +1,307 @@ +use super::auth::RemoteControlConnectionAuth; +use super::auth::load_remote_control_auth; +use super::auth::recover_remote_control_auth; +use super::enroll::REMOTE_CONTROL_ACCOUNT_ID_HEADER; +use super::enroll::format_headers; +use super::enroll::preview_remote_control_response_body; +use super::protocol::normalize_remote_control_base_url; +use axum::http::HeaderMap; +use codex_app_server_protocol::RemoteControlClient; +use codex_app_server_protocol::RemoteControlClientsListOrder; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsListResponse; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; +use codex_app_server_protocol::RemoteControlClientsRevokeResponse; +use codex_login::AuthManager; +use codex_login::default_client::build_reqwest_client; +use serde::Deserialize; +use std::io; +use std::io::ErrorKind; +use std::sync::Arc; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; +use url::Url; + +const REMOTE_CONTROL_CLIENT_MANAGEMENT_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(30); + +#[derive(Debug, Deserialize)] +struct ListRemoteControlClientsResponse { + items: Vec, + #[serde(default)] + cursor: Option, +} + +#[derive(Debug, Deserialize)] +struct RemoteControlClientResponse { + client_id: String, + #[serde(default)] + display_name: Option, + #[serde(default)] + device_type: Option, + #[serde(default)] + platform: Option, + #[serde(default)] + os_version: Option, + #[serde(default)] + device_model: Option, + #[serde(default)] + app_version: Option, + #[serde(default)] + last_seen_at: Option, +} + +enum ClientManagementRequest<'a> { + List { + url: &'a Url, + params: &'a RemoteControlClientsListParams, + }, + Revoke { + url: &'a Url, + }, +} + +struct ClientManagementResponse { + status: axum::http::StatusCode, + headers: HeaderMap, + body: Vec, +} + +pub(super) async fn list_remote_control_clients( + remote_control_url: &str, + auth_manager: &Arc, + params: RemoteControlClientsListParams, +) -> io::Result { + if params.environment_id.is_empty() { + return Err(io::Error::new( + ErrorKind::InvalidInput, + "remote control client list requires environmentId", + )); + } + if params + .limit + .is_some_and(|limit| !(1..=100).contains(&limit)) + { + return Err(io::Error::new( + ErrorKind::InvalidInput, + "remote control client list limit must be between 1 and 100", + )); + } + let url = environment_clients_url(remote_control_url, ¶ms.environment_id)?; + let response = send_client_management_request( + auth_manager, + ClientManagementRequest::List { + url: &url, + params: ¶ms, + }, + "list remote control clients", + ) + .await?; + let ClientManagementResponse { + status, + headers, + body, + } = response; + let body_preview = preview_remote_control_response_body(&body); + ensure_success_response(status, &headers, &url, &body_preview, "client list")?; + let response = serde_json::from_slice::(&body).map_err( + |err| { + io::Error::other(format!( + "failed to parse remote control client list response from `{url}`: HTTP {status}, {}, body: {body_preview}, decode error: {err}", + format_headers(&headers) + )) + }, + )?; + Ok(RemoteControlClientsListResponse { + data: response + .items + .into_iter() + .map(RemoteControlClient::try_from) + .collect::>()?, + next_cursor: response.cursor, + }) +} + +pub(super) async fn revoke_remote_control_client( + remote_control_url: &str, + auth_manager: &Arc, + params: RemoteControlClientsRevokeParams, +) -> io::Result { + if params.environment_id.is_empty() { + return Err(io::Error::new( + ErrorKind::InvalidInput, + "remote control client revoke requires environmentId", + )); + } + if params.client_id.is_empty() { + return Err(io::Error::new( + ErrorKind::InvalidInput, + "remote control client revoke requires clientId", + )); + } + let mut url = environment_clients_url(remote_control_url, ¶ms.environment_id)?; + url.path_segments_mut() + .map_err(|()| { + io::Error::new( + ErrorKind::InvalidInput, + "remote control URL cannot be a base", + ) + })? + .push(¶ms.client_id); + let response = send_client_management_request( + auth_manager, + ClientManagementRequest::Revoke { url: &url }, + "revoke remote control client", + ) + .await?; + let ClientManagementResponse { + status, + headers, + body, + } = response; + let body_preview = preview_remote_control_response_body(&body); + ensure_success_response(status, &headers, &url, &body_preview, "client revoke")?; + Ok(RemoteControlClientsRevokeResponse {}) +} + +async fn send_client_management_request( + auth_manager: &Arc, + request: ClientManagementRequest<'_>, + action: &str, +) -> io::Result { + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); + let auth = load_remote_control_auth(auth_manager).await?; + let response = send_client_management_request_once(&auth, &request, action).await?; + if response.status.as_u16() != 401 + || !recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await + { + return Ok(response); + } + let auth = load_remote_control_auth(auth_manager).await?; + send_client_management_request_once(&auth, &request, action).await +} + +async fn send_client_management_request_once( + auth: &RemoteControlConnectionAuth, + request: &ClientManagementRequest<'_>, + action: &str, +) -> io::Result { + let client = build_reqwest_client(); + let mut auth_headers = HeaderMap::new(); + auth.auth_provider.add_auth_headers(&mut auth_headers); + let request = match request { + ClientManagementRequest::List { url, params } => { + let mut query = Vec::new(); + if let Some(cursor) = ¶ms.cursor { + query.push(("cursor", cursor.clone())); + } + if let Some(limit) = params.limit { + query.push(("limit", limit.to_string())); + } + if let Some(order) = params.order { + query.push(( + "order", + match order { + RemoteControlClientsListOrder::Asc => "asc", + RemoteControlClientsListOrder::Desc => "desc", + } + .to_string(), + )); + } + client.get((*url).clone()).query(&query) + } + ClientManagementRequest::Revoke { url } => client.delete((*url).clone()), + }; + let response = request + .timeout(REMOTE_CONTROL_CLIENT_MANAGEMENT_TIMEOUT) + .headers(auth_headers) + .header(REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id) + .send() + .await + .map_err(|err| io::Error::other(format!("failed to {action}: {err}")))?; + let headers = response.headers().clone(); + let status = response.status(); + let body = response + .bytes() + .await + .map_err(|err| io::Error::other(format!("failed to read {action} response: {err}")))? + .to_vec(); + Ok(ClientManagementResponse { + status, + headers, + body, + }) +} + +fn ensure_success_response( + status: axum::http::StatusCode, + headers: &HeaderMap, + url: &Url, + body_preview: &str, + response_kind: &str, +) -> io::Result<()> { + if status.is_success() { + return Ok(()); + } + let error_kind = match status.as_u16() { + 400 => ErrorKind::InvalidInput, + 401 | 403 => ErrorKind::PermissionDenied, + 404 => ErrorKind::NotFound, + _ => ErrorKind::Other, + }; + Err(io::Error::new( + error_kind, + format!( + "remote control {response_kind} failed at `{url}`: HTTP {status}, {}, body: {body_preview}", + format_headers(headers) + ), + )) +} + +fn environment_clients_url(remote_control_url: &str, environment_id: &str) -> io::Result { + let mut url = normalize_remote_control_base_url(remote_control_url)? + .join("wham/remote/control/environments") + .map_err(io::Error::other)?; + url.path_segments_mut() + .map_err(|()| { + io::Error::new( + ErrorKind::InvalidInput, + "remote control URL cannot be a base", + ) + })? + .push(environment_id) + .push("clients"); + Ok(url) +} + +impl TryFrom for RemoteControlClient { + type Error = io::Error; + + fn try_from(client: RemoteControlClientResponse) -> Result { + Ok(Self { + client_id: client.client_id, + display_name: client.display_name, + device_type: client.device_type, + platform: client.platform, + os_version: client.os_version, + device_model: client.device_model, + app_version: client.app_version, + last_seen_at: client + .last_seen_at + .map(|last_seen_at| { + OffsetDateTime::parse(&last_seen_at, &Rfc3339) + .map(OffsetDateTime::unix_timestamp) + .map_err(|err| { + io::Error::new( + ErrorKind::InvalidData, + format!( + "failed to parse remote control client last_seen_at `{last_seen_at}`: {err}" + ), + ) + }) + }) + .transpose()?, + }) + } +} diff --git a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs index 6037caa651..c491596e65 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs @@ -1,9 +1,13 @@ +use super::auth::RemoteControlConnectionAuth; +use super::pairing_unavailable_error; use super::protocol::EnrollRemoteServerRequest; use super::protocol::EnrollRemoteServerResponse; use super::protocol::RefreshRemoteServerRequest; use super::protocol::RemoteControlTarget; +use super::protocol::StartRemoteControlPairingRequest; +use super::protocol::StartRemoteControlPairingResponse; use axum::http::HeaderMap; -use codex_api::SharedAuthProvider; +use codex_app_server_protocol::RemoteControlPairingStartResponse; use codex_login::default_client::build_reqwest_client; use codex_state::RemoteControlEnrollmentRecord; use codex_state::StateRuntime; @@ -17,6 +21,7 @@ use tracing::info; use tracing::warn; const REMOTE_CONTROL_ENROLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const REMOTE_CONTROL_PAIRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); const REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES: usize = 4096; const REMOTE_CONTROL_SERVER_TOKEN_REFRESH_SKEW_SECS: i64 = 30; @@ -28,6 +33,7 @@ pub(super) const REMOTE_CONTROL_INSTALLATION_ID_HEADER: &str = "x-codex-installa #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct RemoteControlEnrollment { + pub(super) remote_control_target: RemoteControlTarget, pub(super) account_id: String, pub(super) environment_id: String, pub(super) server_id: String, @@ -37,6 +43,99 @@ pub(super) struct RemoteControlEnrollment { } impl RemoteControlEnrollment { + pub(super) async fn start_pairing( + &self, + request: StartRemoteControlPairingRequest, + ) -> io::Result { + if self.should_refresh_server_token() { + return Err(pairing_unavailable_error()); + } + let remote_control_token = self + .remote_control_token + .as_deref() + .ok_or_else(pairing_unavailable_error)?; + + let response = build_reqwest_client() + .post(&self.remote_control_target.pair_url) + .timeout(REMOTE_CONTROL_PAIRING_TIMEOUT) + .bearer_auth(remote_control_token) + .json(&request) + .send() + .await + .map_err(|err| { + io::Error::other(format!( + "failed to start remote control pairing at `{}`: {err}", + self.remote_control_target.pair_url + )) + })?; + let headers = response.headers().clone(); + let status = response.status(); + let body = response.bytes().await.map_err(|err| { + io::Error::other(format!( + "failed to read remote control pairing response from `{}`: {err}", + self.remote_control_target.pair_url + )) + })?; + let body_preview = preview_remote_control_response_body(&body); + if !status.is_success() { + let error_kind = match status.as_u16() { + 401 | 403 => ErrorKind::PermissionDenied, + 404 => ErrorKind::NotFound, + _ => ErrorKind::Other, + }; + return Err(io::Error::new( + error_kind, + format!( + "remote control pairing failed at `{}`: HTTP {status}, {}, body: {body_preview}", + self.remote_control_target.pair_url, + format_headers(&headers) + ), + )); + } + + let pairing = serde_json::from_slice::(&body).map_err( + |err| { + io::Error::other(format!( + "failed to parse remote control pairing response from `{}`: HTTP {status}, {}, body: {body_preview}, decode error: {err}", + self.remote_control_target.pair_url, + format_headers(&headers) + )) + }, + )?; + let StartRemoteControlPairingResponse { + pairing_code, + manual_pairing_code, + server_id, + environment_id, + expires_at, + } = pairing; + if server_id != self.server_id || environment_id != self.environment_id { + return Err(io::Error::other(format!( + "remote control pairing returned mismatched enrollment: expected server_id={}, environment_id={}; got server_id={}, environment_id={}", + self.server_id, self.environment_id, server_id, environment_id + ))); + } + let expires_at = OffsetDateTime::parse(&expires_at, &Rfc3339) + .map_err(|err| { + io::Error::new( + ErrorKind::InvalidData, + format!( + "failed to parse remote control pairing response from `{}`: HTTP {status}, {}, body: {body_preview}, expires_at parse error: {err}", + self.remote_control_target.pair_url, + format_headers(&headers) + ), + ) + })? + .unix_timestamp(); + + Ok(RemoteControlPairingStartResponse { + pairing_code, + manual_pairing_code, + environment_id, + expires_at, + }) + } + pub(super) fn should_refresh_server_token(&self) -> bool { self.remote_control_token.is_none() || self.expires_at.is_none_or(|expires_at| { @@ -52,11 +151,6 @@ impl RemoteControlEnrollment { } } -pub(super) struct RemoteControlConnectionAuth { - pub(super) auth_provider: SharedAuthProvider, - pub(super) account_id: String, -} - pub(super) async fn load_persisted_remote_control_enrollment( state_db: Option<&StateRuntime>, remote_control_target: &RemoteControlTarget, @@ -101,6 +195,7 @@ pub(super) async fn load_persisted_remote_control_enrollment( enrollment.environment_id ); Ok(Some(RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), account_id: enrollment.account_id, environment_id: enrollment.environment_id, server_id: enrollment.server_id, @@ -211,8 +306,14 @@ fn redact_remote_control_response_body(body: &str) -> String { let Some(body_object) = body_json.as_object_mut() else { return body.to_string(); }; - if let Some(remote_control_token) = body_object.get_mut("remote_control_token") { - *remote_control_token = serde_json::Value::String("".to_string()); + for sensitive_field in [ + "remote_control_token", + "pairing_code", + "manual_pairing_code", + ] { + if let Some(value) = body_object.get_mut(sensitive_field) { + *value = serde_json::Value::String("".to_string()); + } } body_json.to_string() } @@ -254,6 +355,7 @@ pub(super) async fn enroll_remote_control_server( ) .await?; let mut enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), account_id: auth.account_id.clone(), environment_id: enrollment_response.environment_id, server_id: enrollment_response.server_id, @@ -271,18 +373,17 @@ pub(super) async fn enroll_remote_control_server( } pub(super) async fn refresh_remote_control_server( - remote_control_target: &RemoteControlTarget, auth: &RemoteControlConnectionAuth, installation_id: &str, enrollment: &mut RemoteControlEnrollment, ) -> io::Result<()> { - let refresh_url = &remote_control_target.refresh_url; + let refresh_url = enrollment.remote_control_target.refresh_url.clone(); let request = RefreshRemoteServerRequest { server_id: enrollment.server_id.clone(), installation_id: installation_id.to_string(), }; let refreshed = send_remote_control_server_request::<_, EnrollRemoteServerResponse>( - refresh_url, + &refresh_url, auth, installation_id, &request, @@ -304,7 +405,7 @@ pub(super) async fn refresh_remote_control_server( update_remote_control_server_token( enrollment, - refresh_url, + &refresh_url, refreshed.remote_control_token, refreshed.expires_at, ) @@ -412,6 +513,8 @@ mod tests { #[test] fn remote_control_enrollment_refreshes_server_token_before_expiry() { let expires_soon = RemoteControlEnrollment { + remote_control_target: normalize_remote_control_url("http://localhost/backend-api/") + .expect("target should normalize"), account_id: "account-a".to_string(), environment_id: "env_first".to_string(), server_id: "srv_e_first".to_string(), @@ -433,12 +536,14 @@ mod tests { fn preview_remote_control_response_body_redacts_server_token() { assert_eq!( serde_json::from_str::(&preview_remote_control_response_body( - br#"{"server_id":"srv_e_test","remote_control_token":"secret"}"# + br#"{"server_id":"srv_e_test","remote_control_token":"secret","pairing_code":"pairing-code","manual_pairing_code":"ABCD-EFGH"}"# )) .expect("redacted response preview should stay valid json"), json!({ "server_id": "srv_e_test", "remote_control_token": "", + "pairing_code": "", + "manual_pairing_code": "", }) ); } @@ -453,6 +558,7 @@ mod tests { normalize_remote_control_url("https://api.chatgpt-staging.com/other/control") .expect("second target should parse"); let first_enrollment = RemoteControlEnrollment { + remote_control_target: first_target.clone(), account_id: "account-a".to_string(), environment_id: "env_first".to_string(), server_id: "srv_e_first".to_string(), @@ -461,6 +567,7 @@ mod tests { expires_at: None, }; let second_enrollment = RemoteControlEnrollment { + remote_control_target: second_target.clone(), account_id: "account-a".to_string(), environment_id: "env_second".to_string(), server_id: "srv_e_second".to_string(), @@ -533,6 +640,7 @@ mod tests { normalize_remote_control_url("https://api.chatgpt-staging.com/other/control") .expect("second target should parse"); let first_enrollment = RemoteControlEnrollment { + remote_control_target: first_target.clone(), account_id: "account-a".to_string(), environment_id: "env_first".to_string(), server_id: "srv_e_first".to_string(), @@ -541,6 +649,7 @@ mod tests { expires_at: None, }; let second_enrollment = RemoteControlEnrollment { + remote_control_target: second_target.clone(), account_id: "account-a".to_string(), environment_id: "env_second".to_string(), server_id: "srv_e_second".to_string(), diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index 357a98a30d..38e34e6d43 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -1,9 +1,15 @@ +mod auth; mod client_tracker; +mod clients; mod enroll; mod protocol; mod segment; mod websocket; +use self::auth::load_remote_control_auth; +use self::auth::recover_remote_control_auth; +use self::enroll::RemoteControlEnrollment; +use self::enroll::refresh_remote_control_server; use crate::transport::remote_control::websocket::RemoteControlChannels; use crate::transport::remote_control::websocket::RemoteControlStatusPublisher; use crate::transport::remote_control::websocket::RemoteControlWebsocket; @@ -15,7 +21,13 @@ use self::protocol::normalize_remote_control_url; use super::CHANNEL_CAPACITY; use super::TransportEvent; use super::next_connection_id; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsListResponse; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; +use codex_app_server_protocol::RemoteControlClientsRevokeResponse; use codex_app_server_protocol::RemoteControlConnectionStatus; +use codex_app_server_protocol::RemoteControlPairingStartParams; +use codex_app_server_protocol::RemoteControlPairingStartResponse; use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_login::AuthManager; use codex_state::StateRuntime; @@ -26,6 +38,7 @@ use std::fmt; use std::io; use std::panic::AssertUnwindSafe; use std::sync::Arc; +use std::sync::Mutex as StdMutex; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::sync::watch; @@ -52,8 +65,13 @@ pub struct RemoteControlHandle { enabled_tx: Arc>, status_tx: Arc>, state_db_available: bool, + remote_control_url: String, + current_enrollment: CurrentRemoteControlEnrollment, + auth_manager: Arc, } +type CurrentRemoteControlEnrollment = Arc>>; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RemoteControlUnavailable; @@ -108,6 +126,7 @@ impl RemoteControlHandle { *state = false; changed }); + clear_current_enrollment(&self.current_enrollment); let status = self.status(); info!( @@ -129,6 +148,104 @@ impl RemoteControlHandle { self.status_tx.subscribe() } + pub async fn start_pairing( + &self, + params: RemoteControlPairingStartParams, + ) -> io::Result { + if !*self.enabled_tx.borrow() { + return Err(Self::pairing_disabled_error()); + } + let mut auth = load_remote_control_auth(&self.auth_manager) + .await + .map_err(|_| pairing_unavailable_error())?; + let mut enrollment = { + let current_enrollment = self + .current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + current_enrollment + .as_ref() + .filter(|enrollment| enrollment.account_id == auth.account_id) + .cloned() + } + .ok_or_else(pairing_unavailable_error)?; + let installation_id = self.status().installation_id; + if enrollment.should_refresh_server_token() { + refresh_pairing_enrollment( + &self.current_enrollment, + &self.auth_manager, + &mut auth, + &installation_id, + &mut enrollment, + ) + .await?; + } + let pairing_request = || protocol::StartRemoteControlPairingRequest { + manual_code: params.manual_code, + }; + let pairing_response = match enrollment.start_pairing(pairing_request()).await { + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { + clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?; + refresh_pairing_enrollment( + &self.current_enrollment, + &self.auth_manager, + &mut auth, + &installation_id, + &mut enrollment, + ) + .await?; + enrollment.start_pairing(pairing_request()).await + } + pairing_response => pairing_response, + }; + if let Err(err) = &pairing_response { + match err.kind() { + io::ErrorKind::NotFound => { + clear_current_enrollment_if_matches(&self.current_enrollment, &enrollment); + return Err(pairing_unavailable_error()); + } + io::ErrorKind::PermissionDenied => { + clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?; + return Err(pairing_unavailable_error()); + } + _ => {} + } + } + if !*self.enabled_tx.borrow() { + return Err(Self::pairing_disabled_error()); + } + let current_auth = load_remote_control_auth(&self.auth_manager) + .await + .map_err(|_| pairing_unavailable_error())?; + if current_auth.account_id != auth.account_id { + return Err(pairing_unavailable_error()); + } + pairing_response + } + + pub async fn list_clients( + &self, + params: RemoteControlClientsListParams, + ) -> io::Result { + clients::list_remote_control_clients(&self.remote_control_url, &self.auth_manager, params) + .await + } + + pub async fn revoke_client( + &self, + params: RemoteControlClientsRevokeParams, + ) -> io::Result { + clients::revoke_remote_control_client(&self.remote_control_url, &self.auth_manager, params) + .await + } + + fn pairing_disabled_error() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + "remote control pairing requires remote control to be enabled", + ) + } + fn publish_status( &self, connection_status: RemoteControlConnectionStatus, @@ -160,6 +277,71 @@ impl RemoteControlHandle { } } +async fn refresh_pairing_enrollment( + current_enrollment: &CurrentRemoteControlEnrollment, + auth_manager: &Arc, + auth: &mut auth::RemoteControlConnectionAuth, + installation_id: &str, + enrollment: &mut RemoteControlEnrollment, +) -> io::Result<()> { + if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await { + if err.kind() != io::ErrorKind::PermissionDenied { + return handle_pairing_refresh_error(current_enrollment, enrollment, err); + } + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); + if !recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await { + return Err(err); + } + *auth = load_remote_control_auth(auth_manager) + .await + .map_err(|_| pairing_unavailable_error())?; + if auth.account_id != enrollment.account_id { + return Err(pairing_unavailable_error()); + } + if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await { + return handle_pairing_refresh_error(current_enrollment, enrollment, err); + } + } + if replace_current_enrollment(current_enrollment, enrollment) { + Ok(()) + } else { + Err(pairing_unavailable_error()) + } +} + +fn handle_pairing_refresh_error( + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &RemoteControlEnrollment, + err: io::Error, +) -> io::Result<()> { + if err.kind() == io::ErrorKind::NotFound { + clear_current_enrollment_if_matches(current_enrollment, enrollment); + Err(pairing_unavailable_error()) + } else { + Err(err) + } +} + +fn clear_pairing_server_token( + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &mut RemoteControlEnrollment, +) -> io::Result<()> { + enrollment.clear_server_token(); + if replace_current_enrollment(current_enrollment, enrollment) { + Ok(()) + } else { + Err(pairing_unavailable_error()) + } +} + +fn pairing_unavailable_error() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + "remote control pairing is unavailable until enrollment completes", + ) +} + fn remote_control_status_with_connection_status( status: &RemoteControlStatusChangedNotification, connection_status: RemoteControlConnectionStatus, @@ -176,6 +358,64 @@ fn remote_control_status_with_connection_status( } } +fn publish_current_enrollment( + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &RemoteControlEnrollment, +) { + *current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(enrollment.clone()); +} + +fn clear_current_enrollment(current_enrollment: &CurrentRemoteControlEnrollment) { + *current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; +} + +fn replace_current_enrollment( + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &RemoteControlEnrollment, +) -> bool { + let mut current_enrollment = current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !current_enrollment + .as_ref() + .is_some_and(|current| same_remote_control_enrollment(current, enrollment)) + { + return false; + } + *current_enrollment = Some(enrollment.clone()); + true +} + +fn clear_current_enrollment_if_matches( + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &RemoteControlEnrollment, +) { + let mut current_enrollment = current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if current_enrollment + .as_ref() + .is_some_and(|current| same_remote_control_enrollment(current, enrollment)) + { + *current_enrollment = None; + } +} + +fn same_remote_control_enrollment( + left: &RemoteControlEnrollment, + right: &RemoteControlEnrollment, +) -> bool { + // A refresh rotates only the bearer. Pairing remains current while the same persisted server + // record is still selected for the current account. + left.account_id == right.account_id + && left.server_id == right.server_id + && left.environment_id == right.environment_id +} + pub async fn start_remote_control( config: RemoteControlStartConfig, state_db: Option>, @@ -198,6 +438,9 @@ pub async fn start_remote_control( }; let (enabled_tx, enabled_rx) = watch::channel(initial_enabled); + let current_enrollment = Arc::new(StdMutex::new(None)); + let websocket_current_enrollment = current_enrollment.clone(); + let handle_auth_manager = auth_manager.clone(); let server_name = gethostname().to_string_lossy().trim().to_string(); let remote_control_url = config.remote_control_url; let installation_id = config.installation_id; @@ -222,6 +465,7 @@ pub async fn start_remote_control( "starting app-server remote control websocket task" ); let remote_control_url_for_log = remote_control_url.clone(); + let handle_remote_control_url = remote_control_url.clone(); let installation_id_for_log = installation_id.clone(); let server_name_for_log = server_name.clone(); let shutdown_token_for_log = shutdown_token.clone(); @@ -245,6 +489,7 @@ pub async fn start_remote_control( RemoteControlChannels { transport_event_tx, status_publisher, + current_enrollment: websocket_current_enrollment, }, shutdown_token, enabled_rx, @@ -289,6 +534,9 @@ pub async fn start_remote_control( enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available, + remote_control_url: handle_remote_control_url, + current_enrollment, + auth_manager: handle_auth_manager, }, )) } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs index b10af42e86..630fd862c8 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs @@ -12,6 +12,7 @@ pub(super) struct RemoteControlTarget { pub(super) websocket_url: String, pub(super) enroll_url: String, pub(super) refresh_url: String, + pub(super) pair_url: String, } #[derive(Debug, Serialize)] @@ -37,6 +38,20 @@ pub(super) struct RefreshRemoteServerRequest { pub(super) installation_id: String, } +#[derive(Debug, Serialize)] +pub(super) struct StartRemoteControlPairingRequest { + pub(super) manual_code: bool, +} + +#[derive(Debug, Deserialize)] +pub(super) struct StartRemoteControlPairingResponse { + pub(super) pairing_code: String, + pub(super) manual_pairing_code: Option, + pub(super) server_id: String, + pub(super) environment_id: String, + pub(super) expires_at: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct ClientId(pub String); @@ -162,6 +177,48 @@ fn is_localhost(host: &Option>) -> bool { pub(super) fn normalize_remote_control_url( remote_control_url: &str, ) -> io::Result { + let remote_control_url = normalize_remote_control_base_url(remote_control_url)?; + let map_url_parse_error = |err: url::ParseError| -> io::Error { + io::Error::new( + ErrorKind::InvalidInput, + format!("invalid remote control URL `{remote_control_url}`: {err}"), + ) + }; + + let enroll_url = remote_control_url + .join("wham/remote/control/server/enroll") + .map_err(map_url_parse_error)?; + let refresh_url = remote_control_url + .join("wham/remote/control/server/refresh") + .map_err(map_url_parse_error)?; + let pair_url = remote_control_url + .join("wham/remote/control/server/pair") + .map_err(map_url_parse_error)?; + let mut websocket_url = remote_control_url + .join("wham/remote/control/server") + .map_err(map_url_parse_error)?; + websocket_url + .set_scheme(if enroll_url.scheme() == "https" { + "wss" + } else { + "ws" + }) + .map_err(|()| { + io::Error::new( + ErrorKind::InvalidInput, + format!("invalid remote control URL `{remote_control_url}`"), + ) + })?; + + Ok(RemoteControlTarget { + websocket_url: websocket_url.to_string(), + enroll_url: enroll_url.to_string(), + refresh_url: refresh_url.to_string(), + pair_url: pair_url.to_string(), + }) +} + +pub(super) fn normalize_remote_control_base_url(remote_control_url: &str) -> io::Result { let map_url_parse_error = |err: url::ParseError| -> io::Error { io::Error::new( ErrorKind::InvalidInput, @@ -183,31 +240,14 @@ pub(super) fn normalize_remote_control_url( remote_control_url.set_path(&normalized_path); } - let enroll_url = remote_control_url - .join("wham/remote/control/server/enroll") - .map_err(map_url_parse_error)?; - let refresh_url = remote_control_url - .join("wham/remote/control/server/refresh") - .map_err(map_url_parse_error)?; - let mut websocket_url = remote_control_url - .join("wham/remote/control/server") - .map_err(map_url_parse_error)?; - let host = enroll_url.host(); - match enroll_url.scheme() { - "https" if is_localhost(&host) || is_allowed_remote_control_chatgpt_host(&host) => { - websocket_url.set_scheme("wss").map_err(map_scheme_error)?; - } - "http" if is_localhost(&host) => { - websocket_url.set_scheme("ws").map_err(map_scheme_error)?; - } + let host = remote_control_url.host(); + match remote_control_url.scheme() { + "https" if is_localhost(&host) || is_allowed_remote_control_chatgpt_host(&host) => {} + "http" if is_localhost(&host) => {} _ => return Err(map_scheme_error(())), } - Ok(RemoteControlTarget { - websocket_url: websocket_url.to_string(), - enroll_url: enroll_url.to_string(), - refresh_url: refresh_url.to_string(), - }) + Ok(remote_control_url) } #[cfg(test)] @@ -227,6 +267,8 @@ mod tests { .to_string(), refresh_url: "https://chatgpt.com/backend-api/wham/remote/control/server/refresh" .to_string(), + pair_url: "https://chatgpt.com/backend-api/wham/remote/control/server/pair" + .to_string(), } ); assert_eq!( @@ -242,6 +284,9 @@ mod tests { refresh_url: "https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/refresh" .to_string(), + pair_url: + "https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/pair" + .to_string(), } ); } @@ -258,6 +303,8 @@ mod tests { .to_string(), refresh_url: "http://localhost:8080/backend-api/wham/remote/control/server/refresh" .to_string(), + pair_url: "http://localhost:8080/backend-api/wham/remote/control/server/pair" + .to_string(), } ); assert_eq!( @@ -271,6 +318,8 @@ mod tests { refresh_url: "https://localhost:8443/backend-api/wham/remote/control/server/refresh" .to_string(), + pair_url: "https://localhost:8443/backend-api/wham/remote/control/server/pair" + .to_string(), } ); } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 85bcf09222..a1a284da9b 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -20,6 +20,7 @@ use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::RemoteControlConnectionStatus; +use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_app_server_protocol::ServerNotification; use codex_config::types::AuthCredentialsStoreMode; @@ -39,7 +40,9 @@ use pretty_assertions::assert_eq; use serde_json::json; use std::collections::BTreeMap; use std::sync::Arc; +use std::sync::Mutex as StdMutex; use tempfile::TempDir; +use time::OffsetDateTime; use tokio::io::AsyncBufReadExt; use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; @@ -57,7 +60,11 @@ use tokio_tungstenite::accept_hdr_async; use tokio_tungstenite::tungstenite; use tokio_util::sync::CancellationToken; +mod clients_tests; +mod pairing_tests; + const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; +const TEST_REMOTE_CONTROL_URL: &str = "http://127.0.0.1:1/backend-api/wham/remote/control"; const TEST_REMOTE_CONTROL_SERVER_TOKEN: &str = "Remote Control Token"; const TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN: &str = "Refreshed Remote Control Token"; const TEST_REMOTE_CONTROL_SERVER_TOKEN_EXPIRES_AT: &str = "2999-01-01T00:00:00Z"; @@ -128,6 +135,41 @@ fn test_server_name() -> String { gethostname().to_string_lossy().trim().to_string() } +fn remote_control_handle_with_current_enrollment( + remote_control_url: &str, + auth_manager: Arc, +) -> RemoteControlHandle { + let (enabled_tx, _enabled_rx) = watch::channel(/*init*/ true); + let (status_tx, _status_rx) = watch::channel(RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Connecting, + server_name: test_server_name(), + installation_id: TEST_INSTALLATION_ID.to_string(), + environment_id: Some("env_test".to_string()), + }); + let remote_control_target = normalize_remote_control_url(remote_control_url) + .expect("remote control target should normalize"); + let current_enrollment = Arc::new(StdMutex::new(Some(RemoteControlEnrollment { + remote_control_target, + account_id: "account_id".to_string(), + environment_id: "env_test".to_string(), + server_id: "srv_e_test".to_string(), + server_name: test_server_name(), + remote_control_token: Some(TEST_REMOTE_CONTROL_SERVER_TOKEN.to_string()), + expires_at: Some( + OffsetDateTime::from_unix_timestamp(33_336_362_096) + .expect("future timestamp should parse"), + ), + }))); + RemoteControlHandle { + enabled_tx: Arc::new(enabled_tx), + status_tx: Arc::new(status_tx), + state_db_available: true, + remote_control_url: remote_control_url.to_string(), + current_enrollment, + auth_manager, + } +} + fn remote_control_server_token_response( server_id: &str, environment_id: &str, @@ -1313,6 +1355,7 @@ async fn remote_control_http_mode_refreshes_persisted_enrollment_before_connecti let remote_control_target = normalize_remote_control_url(&remote_control_url).expect("target should parse"); let persisted_enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), account_id: "account_id".to_string(), environment_id: "env_persisted".to_string(), server_id: "srv_e_persisted".to_string(), @@ -1418,6 +1461,7 @@ async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() { normalize_remote_control_url(&remote_control_url).expect("target should parse"); let app_server_client_name = "stdio-client"; let persisted_enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), account_id: "account_id".to_string(), environment_id: "env_persisted".to_string(), server_id: "srv_e_persisted".to_string(), @@ -1505,7 +1549,10 @@ async fn remote_control_waits_for_account_id_before_enrolling() { ) .await; let expected_server_name = gethostname().to_string_lossy().trim().to_string(); + let expected_remote_control_target = normalize_remote_control_url(&remote_control_url) + .expect("remote control target should normalize"); let expected_enrollment = RemoteControlEnrollment { + remote_control_target: expected_remote_control_target, account_id: "account_id".to_string(), environment_id: "env_ready".to_string(), server_id: "srv_e_ready".to_string(), @@ -1583,6 +1630,7 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen normalize_remote_control_url(&remote_control_url).expect("target should parse"); let expected_server_name = gethostname().to_string_lossy().trim().to_string(); let stale_enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), account_id: "account_id".to_string(), environment_id: "env_stale".to_string(), server_id: "srv_e_stale".to_string(), @@ -1591,6 +1639,7 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen expires_at: None, }; let refreshed_enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), account_id: "account_id".to_string(), environment_id: "env_refreshed".to_string(), server_id: "srv_e_refreshed".to_string(), @@ -1700,6 +1749,7 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() normalize_remote_control_url(&remote_control_url).expect("target should parse"); let expected_server_name = gethostname().to_string_lossy().trim().to_string(); let stale_enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), account_id: "account_id".to_string(), environment_id: "env_stale".to_string(), server_id: "srv_e_stale".to_string(), @@ -1708,6 +1758,7 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() expires_at: None, }; let refreshed_enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), account_id: "account_id".to_string(), environment_id: "env_refreshed".to_string(), server_id: "srv_e_refreshed".to_string(), diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs new file mode 100644 index 0000000000..22aa290762 --- /dev/null +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs @@ -0,0 +1,359 @@ +use super::super::clients::list_remote_control_clients; +use super::super::clients::revoke_remote_control_client; +use super::*; +use codex_app_server_protocol::RemoteControlClient; +use codex_app_server_protocol::RemoteControlClientsListOrder; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsListResponse; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; +use codex_app_server_protocol::RemoteControlClientsRevokeResponse; +use pretty_assertions::assert_eq; + +fn client_management_handle( + remote_control_url: String, + auth_manager: Arc, +) -> RemoteControlHandle { + let (enabled_tx, _enabled_rx) = watch::channel(/*init*/ false); + let (status_tx, _status_rx) = watch::channel(RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Disabled, + server_name: test_server_name(), + installation_id: TEST_INSTALLATION_ID.to_string(), + environment_id: None, + }); + RemoteControlHandle { + enabled_tx: Arc::new(enabled_tx), + status_tx: Arc::new(status_tx), + state_db_available: false, + remote_control_url, + current_enrollment: Arc::new(StdMutex::new(None)), + auth_manager, + } +} + +fn empty_client_list() -> serde_json::Value { + json!({ + "items": [], + "cursor": null, + }) +} + +#[tokio::test] +async fn remote_control_handle_lists_clients_while_disabled() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let request = accept_http_request(&listener).await; + assert_eq!( + request.request_line, + "GET /backend-api/wham/remote/control/environments/env%20%2F%3F/clients?cursor=cursor+%2F%3F&limit=10&order=asc HTTP/1.1" + ); + assert_eq!( + request.headers.get("authorization"), + Some(&"Bearer Access Token".to_string()) + ); + assert_eq!( + request.headers.get(REMOTE_CONTROL_ACCOUNT_ID_HEADER), + Some(&"account_id".to_string()) + ); + respond_with_json( + request.stream, + json!({ + "items": [{ + "client_id": "client-123", + "account_user_id": "user-123", + "enrollment_status": "enrolled_device_key", + "display_name": "Anton Phone", + "device_type": "phone", + "platform": "ios", + "os_version": "19.0", + "device_model": "iPhone", + "app_version": "1.2.3", + "last_seen_at": "2026-03-05T07:00:00Z", + "last_seen_city": "San Francisco", + }], + "cursor": "next-cursor", + }), + ) + .await; + }); + let handle = client_management_handle(remote_control_url, remote_control_auth_manager()); + + let response = handle + .list_clients(RemoteControlClientsListParams { + environment_id: "env /?".to_string(), + cursor: Some("cursor /?".to_string()), + limit: Some(10), + order: Some(RemoteControlClientsListOrder::Asc), + }) + .await + .expect("client list should succeed while remote control is disabled"); + server_task.await.expect("server task should finish"); + + assert_eq!( + response, + RemoteControlClientsListResponse { + data: vec![RemoteControlClient { + client_id: "client-123".to_string(), + display_name: Some("Anton Phone".to_string()), + device_type: Some("phone".to_string()), + platform: Some("ios".to_string()), + os_version: Some("19.0".to_string()), + device_model: Some("iPhone".to_string()), + app_version: Some("1.2.3".to_string()), + last_seen_at: Some(1_772_694_000), + }], + next_cursor: Some("next-cursor".to_string()), + } + ); +} + +#[tokio::test] +async fn remote_control_handle_revokes_client_while_disabled() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let request = accept_http_request(&listener).await; + assert_eq!( + request.request_line, + "DELETE /backend-api/wham/remote/control/environments/env%20%2F%3F/clients/client%20%2F%3F HTTP/1.1" + ); + respond_with_status(request.stream, "204 No Content", "").await; + }); + let handle = client_management_handle(remote_control_url, remote_control_auth_manager()); + + let response = handle + .revoke_client(RemoteControlClientsRevokeParams { + environment_id: "env /?".to_string(), + client_id: "client /?".to_string(), + }) + .await + .expect("client revoke should succeed while remote control is disabled"); + server_task.await.expect("server task should finish"); + + assert_eq!(response, RemoteControlClientsRevokeResponse {}); +} + +#[tokio::test] +async fn list_remote_control_clients_recovers_auth_after_unauthorized() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let stale_request = accept_http_request(&listener).await; + assert_eq!( + stale_request.headers.get("authorization"), + Some(&"Bearer stale-token".to_string()) + ); + respond_with_status(stale_request.stream, "401 Unauthorized", "").await; + + let recovered_request = accept_http_request(&listener).await; + assert_eq!( + recovered_request.headers.get("authorization"), + Some(&"Bearer fresh-token".to_string()) + ); + respond_with_json(recovered_request.stream, empty_client_list()).await; + }); + let codex_home = TempDir::new().expect("temp dir should create"); + let mut stale_auth = remote_control_auth_dot_json(Some("account_id")); + stale_auth + .tokens + .as_mut() + .expect("stale auth should include tokens") + .access_token = "stale-token".to_string(); + save_auth( + codex_home.path(), + &stale_auth, + AuthCredentialsStoreMode::File, + ) + .expect("stale auth should save"); + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); + fresh_auth + .tokens + .as_mut() + .expect("fresh auth should include tokens") + .access_token = "fresh-token".to_string(); + save_auth( + codex_home.path(), + &fresh_auth, + AuthCredentialsStoreMode::File, + ) + .expect("fresh auth should save"); + + let response = list_remote_control_clients( + &remote_control_url, + &auth_manager, + RemoteControlClientsListParams { + environment_id: "env-123".to_string(), + ..Default::default() + }, + ) + .await + .expect("client list should recover auth"); + server_task.await.expect("server task should finish"); + + assert_eq!( + response, + RemoteControlClientsListResponse { + data: Vec::new(), + next_cursor: None, + } + ); +} + +#[tokio::test] +async fn list_remote_control_clients_retries_unauthorized_only_once() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let stale_request = accept_http_request(&listener).await; + assert_eq!( + stale_request.headers.get("authorization"), + Some(&"Bearer stale-token".to_string()) + ); + respond_with_status(stale_request.stream, "401 Unauthorized", "").await; + + let recovered_request = accept_http_request(&listener).await; + assert_eq!( + recovered_request.headers.get("authorization"), + Some(&"Bearer fresh-token".to_string()) + ); + respond_with_status(recovered_request.stream, "401 Unauthorized", "").await; + + assert!( + timeout(Duration::from_millis(100), accept_http_request(&listener)) + .await + .is_err() + ); + }); + let codex_home = TempDir::new().expect("temp dir should create"); + let mut stale_auth = remote_control_auth_dot_json(Some("account_id")); + stale_auth + .tokens + .as_mut() + .expect("stale auth should include tokens") + .access_token = "stale-token".to_string(); + save_auth( + codex_home.path(), + &stale_auth, + AuthCredentialsStoreMode::File, + ) + .expect("stale auth should save"); + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); + fresh_auth + .tokens + .as_mut() + .expect("fresh auth should include tokens") + .access_token = "fresh-token".to_string(); + save_auth( + codex_home.path(), + &fresh_auth, + AuthCredentialsStoreMode::File, + ) + .expect("fresh auth should save"); + + let err = list_remote_control_clients( + &remote_control_url, + &auth_manager, + RemoteControlClientsListParams { + environment_id: "env-123".to_string(), + ..Default::default() + }, + ) + .await + .expect_err("second unauthorized response should fail"); + server_task.await.expect("server task should finish"); + + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); +} + +#[tokio::test] +async fn revoke_remote_control_client_does_not_retry_forbidden() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let request = accept_http_request(&listener).await; + respond_with_status_and_headers( + request.stream, + "403 Forbidden", + &[("x-request-id", "request-123"), ("cf-ray", "ray-123")], + "forbidden", + ) + .await; + }); + + let err = revoke_remote_control_client( + &remote_control_url, + &remote_control_auth_manager(), + RemoteControlClientsRevokeParams { + environment_id: "env-123".to_string(), + client_id: "client-123".to_string(), + }, + ) + .await + .expect_err("forbidden revoke should fail"); + server_task.await.expect("server task should finish"); + + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + assert_eq!( + err.to_string(), + format!( + "remote control client revoke failed at `{remote_control_url}wham/remote/control/environments/env-123/clients/client-123`: HTTP 403 Forbidden, request-id: request-123, cf-ray: ray-123, body: forbidden" + ) + ); +} + +#[tokio::test] +async fn list_remote_control_clients_preserves_decode_error_context() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let request = accept_http_request(&listener).await; + respond_with_status(request.stream, "200 OK", "{").await; + }); + + let err = list_remote_control_clients( + &remote_control_url, + &remote_control_auth_manager(), + RemoteControlClientsListParams { + environment_id: "env-123".to_string(), + ..Default::default() + }, + ) + .await + .expect_err("malformed client list should fail"); + server_task.await.expect("server task should finish"); + + assert!( + err.to_string().contains( + "failed to parse remote control client list response from `http://127.0.0.1:" + ) + ); + assert!(err.to_string().contains("HTTP 200 OK")); + assert!(err.to_string().contains("body: {")); + assert!(err.to_string().contains("decode error:")); +} diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs new file mode 100644 index 0000000000..176aeb4ed5 --- /dev/null +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs @@ -0,0 +1,494 @@ +use super::super::protocol::StartRemoteControlPairingRequest; +use super::*; +use pretty_assertions::assert_eq; + +fn remote_control_enrollment( + remote_control_url: &str, + remote_control_token: &str, +) -> RemoteControlEnrollment { + RemoteControlEnrollment { + remote_control_target: normalize_remote_control_url(remote_control_url) + .expect("target should normalize"), + account_id: "account-id".to_string(), + environment_id: "environment-id".to_string(), + server_id: "server-id".to_string(), + server_name: "server-name".to_string(), + remote_control_token: Some(remote_control_token.to_string()), + expires_at: Some( + OffsetDateTime::from_unix_timestamp(33_336_362_096) + .expect("future timestamp should parse"), + ), + } +} + +async fn pairing_error(status: &'static str, body: &'static str) -> (String, String) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let expected_pair_url = normalize_remote_control_url(&remote_control_url) + .expect("target should normalize") + .pair_url; + let server_task = tokio::spawn(async move { + let pairing_request = accept_http_request(&listener).await; + respond_with_status_and_headers( + pairing_request.stream, + status, + &[("x-request-id", "request-123"), ("cf-ray", "ray-123")], + body, + ) + .await; + }); + + let err = remote_control_enrollment(&remote_control_url, "remote-control-token") + .start_pairing(StartRemoteControlPairingRequest { manual_code: false }) + .await + .expect_err("pairing should fail"); + server_task.await.expect("server task should finish"); + (err.to_string(), expected_pair_url) +} + +async fn pairing_response_error(body: serde_json::Value) -> String { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let pairing_request = accept_http_request(&listener).await; + respond_with_json(pairing_request.stream, body).await; + }); + + let err = remote_control_enrollment(&remote_control_url, "remote-control-token") + .start_pairing(StartRemoteControlPairingRequest { manual_code: false }) + .await + .expect_err("pairing should fail"); + server_task.await.expect("server task should finish"); + err.to_string() +} + +#[tokio::test] +async fn remote_control_handle_starts_pairing_before_websocket_connects() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let refresh_request = accept_http_request(&listener).await; + assert_eq!( + refresh_request.request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + assert_eq!( + serde_json::from_str::(&refresh_request.body) + .expect("refresh request body should deserialize"), + json!({ + "server_id": "srv_e_test", + "installation_id": TEST_INSTALLATION_ID, + }) + ); + respond_with_json( + refresh_request.stream, + remote_control_server_token_response( + "srv_e_test", + "env_test", + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + + let pairing_request = accept_http_request(&listener).await; + assert_eq!( + pairing_request.request_line, + "POST /backend-api/wham/remote/control/server/pair HTTP/1.1" + ); + assert_eq!( + pairing_request.headers.get("authorization"), + Some(&format!( + "Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}" + )) + ); + assert_eq!( + serde_json::from_str::(&pairing_request.body) + .expect("pairing request body should deserialize"), + json!({ "manual_code": true }) + ); + respond_with_json( + pairing_request.stream, + json!({ + "pairing_code": "pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": "srv_e_test", + "environment_id": "env_test", + "expires_at": "3026-05-22T12:34:56Z", + }), + ) + .await; + }); + let remote_handle = remote_control_handle_with_current_enrollment( + &remote_control_url, + remote_control_auth_manager(), + ); + remote_handle + .current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_mut() + .expect("current enrollment should exist") + .expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29)); + + let response = remote_handle + .start_pairing(RemoteControlPairingStartParams { manual_code: true }) + .await + .expect("pairing should use the current server before websocket connect"); + server_task.await.expect("server task should finish"); + + assert_eq!( + response, + RemoteControlPairingStartResponse { + pairing_code: "pairing-code".to_string(), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + environment_id: "env_test".to_string(), + expires_at: 33_336_362_096, + } + ); +} + +#[tokio::test] +async fn remote_control_handle_refreshes_after_pairing_auth_failure() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let stale_pairing_request = accept_http_request(&listener).await; + assert_eq!( + stale_pairing_request.request_line, + "POST /backend-api/wham/remote/control/server/pair HTTP/1.1" + ); + assert_eq!( + stale_pairing_request.headers.get("authorization"), + Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}")) + ); + respond_with_status(stale_pairing_request.stream, "401 Unauthorized", "").await; + + let refresh_request = accept_http_request(&listener).await; + assert_eq!( + refresh_request.request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + assert_eq!( + refresh_request.headers.get("authorization"), + Some(&"Bearer Access Token".to_string()) + ); + respond_with_json( + refresh_request.stream, + remote_control_server_token_response( + "srv_e_test", + "env_test", + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + + let refreshed_pairing_request = accept_http_request(&listener).await; + assert_eq!( + refreshed_pairing_request.request_line, + "POST /backend-api/wham/remote/control/server/pair HTTP/1.1" + ); + assert_eq!( + refreshed_pairing_request.headers.get("authorization"), + Some(&format!( + "Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}" + )) + ); + respond_with_json( + refreshed_pairing_request.stream, + json!({ + "pairing_code": "pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": "srv_e_test", + "environment_id": "env_test", + "expires_at": "3026-05-22T12:34:56Z", + }), + ) + .await; + }); + let remote_handle = remote_control_handle_with_current_enrollment( + &remote_control_url, + remote_control_auth_manager(), + ); + + let response = remote_handle + .start_pairing(RemoteControlPairingStartParams::default()) + .await + .expect("pairing should refresh after server token auth failure"); + server_task.await.expect("server task should finish"); + + assert_eq!( + response, + RemoteControlPairingStartResponse { + pairing_code: "pairing-code".to_string(), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + environment_id: "env_test".to_string(), + expires_at: 33_336_362_096, + } + ); +} + +#[tokio::test] +async fn remote_control_handle_recovers_auth_before_refreshing_pairing() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let stale_refresh_request = accept_http_request(&listener).await; + assert_eq!( + stale_refresh_request.request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + assert_eq!( + stale_refresh_request.headers.get("authorization"), + Some(&"Bearer stale-token".to_string()) + ); + respond_with_status(stale_refresh_request.stream, "401 Unauthorized", "").await; + + let recovered_refresh_request = accept_http_request(&listener).await; + assert_eq!( + recovered_refresh_request.request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + assert_eq!( + recovered_refresh_request.headers.get("authorization"), + Some(&"Bearer fresh-token".to_string()) + ); + respond_with_json( + recovered_refresh_request.stream, + remote_control_server_token_response( + "srv_e_test", + "env_test", + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + + let pairing_request = accept_http_request(&listener).await; + assert_eq!( + pairing_request.request_line, + "POST /backend-api/wham/remote/control/server/pair HTTP/1.1" + ); + assert_eq!( + pairing_request.headers.get("authorization"), + Some(&format!( + "Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}" + )) + ); + respond_with_json( + pairing_request.stream, + json!({ + "pairing_code": "pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": "srv_e_test", + "environment_id": "env_test", + "expires_at": "3026-05-22T12:34:56Z", + }), + ) + .await; + }); + let codex_home = TempDir::new().expect("temp dir should create"); + let mut stale_auth = remote_control_auth_dot_json(Some("account_id")); + stale_auth + .tokens + .as_mut() + .expect("stale auth should include tokens") + .access_token = "stale-token".to_string(); + save_auth( + codex_home.path(), + &stale_auth, + AuthCredentialsStoreMode::File, + ) + .expect("stale auth should save"); + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); + fresh_auth + .tokens + .as_mut() + .expect("fresh auth should include tokens") + .access_token = "fresh-token".to_string(); + save_auth( + codex_home.path(), + &fresh_auth, + AuthCredentialsStoreMode::File, + ) + .expect("fresh auth should save"); + let remote_handle = + remote_control_handle_with_current_enrollment(&remote_control_url, auth_manager); + remote_handle + .current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_mut() + .expect("current enrollment should exist") + .expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29)); + + let response = remote_handle + .start_pairing(RemoteControlPairingStartParams::default()) + .await + .expect("pairing should refresh after auth recovery"); + server_task.await.expect("server task should finish"); + + assert_eq!( + response, + RemoteControlPairingStartResponse { + pairing_code: "pairing-code".to_string(), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + environment_id: "env_test".to_string(), + expires_at: 33_336_362_096, + } + ); +} + +#[tokio::test] +async fn start_remote_control_pairing_preserves_backend_error_context() { + let (err, expected_pair_url) = + pairing_error("503 Service Unavailable", "pairing unavailable").await; + + assert_eq!( + err, + format!( + "remote control pairing failed at `{expected_pair_url}`: HTTP 503 Service Unavailable, request-id: request-123, cf-ray: ray-123, body: pairing unavailable" + ) + ); +} + +#[tokio::test] +async fn start_remote_control_pairing_preserves_decode_error_context() { + let (err, expected_pair_url) = pairing_error("200 OK", "{").await; + assert!(err.contains(&format!( + "failed to parse remote control pairing response from `{expected_pair_url}`: HTTP 200 OK" + ))); + assert!(err.contains("request-id: request-123")); + assert!(err.contains("cf-ray: ray-123")); + assert!(err.contains("body: {")); + assert!(err.contains("decode error:")); +} + +#[tokio::test] +async fn start_remote_control_pairing_rejects_mismatched_backend_enrollment() { + assert_eq!( + pairing_response_error(json!({ + "pairing_code": "pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": "other-server-id", + "environment_id": "other-environment-id", + "expires_at": "3026-05-22T12:34:56Z", + })) + .await, + "remote control pairing returned mismatched enrollment: expected server_id=server-id, environment_id=environment-id; got server_id=other-server-id, environment_id=other-environment-id" + ); +} + +#[tokio::test] +async fn start_remote_control_pairing_preserves_expiry_parse_error_context() { + let err = pairing_response_error(json!({ + "pairing_code": "pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": "server-id", + "environment_id": "environment-id", + "expires_at": "not-a-timestamp", + })) + .await; + + assert!(err.contains("failed to parse remote control pairing response")); + assert!(err.contains("HTTP 200 OK")); + assert!(err.contains("request-id: ")); + assert!(err.contains("cf-ray: ")); + assert!(err.contains("\"expires_at\":\"not-a-timestamp\"")); + assert!(err.contains("expires_at parse error:")); +} + +#[tokio::test] +async fn remote_control_handle_disable_clears_current_enrollment() { + let remote_handle = remote_control_handle_with_current_enrollment( + TEST_REMOTE_CONTROL_URL, + remote_control_auth_manager(), + ); + + remote_handle.disable(); + remote_handle.enable().expect("enable should succeed"); + assert_eq!( + remote_handle + .start_pairing(RemoteControlPairingStartParams::default()) + .await + .expect_err("re-enabled remote control should wait for a current server") + .to_string(), + "remote control pairing is unavailable until enrollment completes" + ); +} + +#[tokio::test] +async fn remote_control_handle_discards_pairing_response_after_auth_change() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + save_auth( + codex_home.path(), + &remote_control_auth_dot_json(Some("account_id")), + AuthCredentialsStoreMode::File, + ) + .expect("initial auth should save"); + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let remote_handle = + remote_control_handle_with_current_enrollment(&remote_control_url, auth_manager.clone()); + let pairing_task = tokio::spawn({ + let remote_handle = remote_handle.clone(); + async move { + remote_handle + .start_pairing(RemoteControlPairingStartParams::default()) + .await + } + }); + + let pairing_request = accept_http_request(&listener).await; + save_auth( + codex_home.path(), + &remote_control_auth_dot_json(Some("next_account_id")), + AuthCredentialsStoreMode::File, + ) + .expect("next auth should save"); + auth_manager.reload().await; + respond_with_json( + pairing_request.stream, + json!({ + "pairing_code": "stale-pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": "srv_e_test", + "environment_id": "env_test", + "expires_at": "3026-05-22T12:34:56Z", + }), + ) + .await; + + assert_eq!( + pairing_task + .await + .expect("pairing task should join") + .expect_err("stale pairing response should be discarded") + .to_string(), + "remote control pairing is unavailable until enrollment completes" + ); +} diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 21710b5e26..60792df54c 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -1,7 +1,23 @@ +use super::CurrentRemoteControlEnrollment; +use super::clear_current_enrollment; +use super::protocol::ClientEnvelope; +use super::protocol::ClientEvent; +use super::protocol::ClientId; +use super::protocol::RemoteControlTarget; +use super::protocol::ServerEnvelope; +use super::protocol::StreamId; +use super::publish_current_enrollment; +use super::remote_control_status_with_connection_status; +use super::segment::ClientSegmentObservation; +use super::segment::ClientSegmentReassembler; +use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; +use super::segment::split_server_envelope_for_transport; use crate::transport::TransportEvent; +use crate::transport::remote_control::auth::RemoteControlConnectionAuth; +use crate::transport::remote_control::auth::load_remote_control_auth; +use crate::transport::remote_control::auth::recover_remote_control_auth; use crate::transport::remote_control::client_tracker::ClientTracker; use crate::transport::remote_control::client_tracker::REMOTE_CONTROL_IDLE_SWEEP_INTERVAL; -use crate::transport::remote_control::enroll::RemoteControlConnectionAuth; use crate::transport::remote_control::enroll::RemoteControlEnrollment; use crate::transport::remote_control::enroll::enroll_remote_control_server; use crate::transport::remote_control::enroll::format_headers; @@ -9,18 +25,6 @@ use crate::transport::remote_control::enroll::load_persisted_remote_control_enro use crate::transport::remote_control::enroll::preview_remote_control_response_body; use crate::transport::remote_control::enroll::refresh_remote_control_server; use crate::transport::remote_control::enroll::update_persisted_remote_control_enrollment; - -use super::protocol::ClientEnvelope; -use super::protocol::ClientEvent; -use super::protocol::ClientId; -use super::protocol::RemoteControlTarget; -use super::protocol::ServerEnvelope; -use super::protocol::StreamId; -use super::remote_control_status_with_connection_status; -use super::segment::ClientSegmentObservation; -use super::segment::ClientSegmentReassembler; -use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; -use super::segment::split_server_envelope_for_transport; use axum::http::HeaderValue; use base64::Engine; use codex_app_server_protocol::RemoteControlConnectionStatus; @@ -251,6 +255,7 @@ pub(crate) struct RemoteControlWebsocket { enrollment: Option, auth_recovery: UnauthorizedRecovery, auth_change_rx: watch::Receiver, + current_enrollment: CurrentRemoteControlEnrollment, client_tracker: Arc>, state: Arc>, server_event_rx: Arc>>, @@ -288,6 +293,7 @@ enum ConnectionEndReason { pub(super) struct RemoteControlChannels { pub(super) transport_event_tx: mpsc::Sender, pub(super) status_publisher: RemoteControlStatusPublisher, + pub(super) current_enrollment: CurrentRemoteControlEnrollment, } #[derive(Clone)] @@ -404,6 +410,7 @@ impl RemoteControlWebsocket { enrollment: None, auth_recovery, auth_change_rx, + current_enrollment: channels.current_enrollment, client_tracker: Arc::new(Mutex::new(client_tracker)), state: Arc::new(Mutex::new(WebsocketState { outbound_buffer, @@ -611,6 +618,7 @@ impl RemoteControlWebsocket { &mut self.enrollment, connect_options, &self.status_publisher, + &self.current_enrollment, ) => connect_result, }; @@ -1166,51 +1174,6 @@ fn build_remote_control_websocket_request( Ok(request) } -pub(crate) async fn load_remote_control_auth( - auth_manager: &Arc, -) -> io::Result { - let mut reloaded = false; - let auth = loop { - let Some(auth) = auth_manager.auth().await else { - if reloaded { - return Err(io::Error::new( - ErrorKind::PermissionDenied, - "remote control requires ChatGPT authentication", - )); - } - auth_manager.reload().await; - reloaded = true; - continue; - }; - if !auth.uses_codex_backend() { - break auth; - } - if auth.get_account_id().is_none() && !reloaded { - auth_manager.reload().await; - reloaded = true; - continue; - } - break auth; - }; - - if !auth.uses_codex_backend() { - return Err(io::Error::new( - ErrorKind::PermissionDenied, - "remote control requires ChatGPT authentication; API key auth is not supported", - )); - } - - Ok(RemoteControlConnectionAuth { - auth_provider: codex_model_provider::auth_provider_from_auth(&auth), - account_id: auth.get_account_id().ok_or_else(|| { - io::Error::new( - ErrorKind::WouldBlock, - "remote control enrollment is waiting for a ChatGPT account id", - ) - })?, - }) -} - fn next_reconnect_delay(reconnect_attempt: &mut u64) -> (std::time::Duration, bool) { let reconnect_delay = backoff(*reconnect_attempt).min(REMOTE_CONTROL_RECONNECT_BACKOFF_CAP); let reconnect_backoff_reset = reconnect_delay == REMOTE_CONTROL_RECONNECT_BACKOFF_CAP; @@ -1229,6 +1192,7 @@ pub(super) async fn connect_remote_control_websocket( enrollment: &mut Option, connect_options: RemoteControlConnectOptions<'_>, status_publisher: &RemoteControlStatusPublisher, + current_enrollment: &CurrentRemoteControlEnrollment, ) -> io::Result<( WebSocketStream>, tungstenite::http::Response<()>, @@ -1237,6 +1201,7 @@ pub(super) async fn connect_remote_control_websocket( let Some(state_db) = state_db else { *enrollment = None; + clear_current_enrollment(current_enrollment); return Err(io::Error::new( ErrorKind::NotFound, "remote control requires sqlite state db", @@ -1249,6 +1214,7 @@ pub(super) async fn connect_remote_control_websocket( if err.kind() == ErrorKind::PermissionDenied { *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); + clear_current_enrollment(current_enrollment); } return Err(err); } @@ -1265,6 +1231,10 @@ pub(super) async fn connect_remote_control_websocket( ); *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); + clear_current_enrollment(current_enrollment); + } + if let Some(enrollment) = enrollment.as_mut() { + enrollment.remote_control_target = remote_control_target.clone(); } if let Some(enrollment) = enrollment.as_ref() { @@ -1321,13 +1291,8 @@ pub(super) async fn connect_remote_control_websocket( let enrollment_ref = enrollment.as_mut().ok_or_else(|| { io::Error::other("missing remote control enrollment before server refresh") })?; - match refresh_remote_control_server( - remote_control_target, - &auth, - connect_options.installation_id, - enrollment_ref, - ) - .await + match refresh_remote_control_server(&auth, connect_options.installation_id, enrollment_ref) + .await { Ok(()) => {} Err(err) if err.kind() == ErrorKind::NotFound => { @@ -1342,6 +1307,7 @@ pub(super) async fn connect_remote_control_websocket( connect_options.app_server_client_name, enrollment, status_publisher, + current_enrollment, ) .await; enroll_remote_control_server_if_missing( @@ -1374,6 +1340,7 @@ pub(super) async fn connect_remote_control_websocket( let enrollment_ref = enrollment.as_ref().ok_or_else(|| { io::Error::other("missing remote control enrollment after enrollment step") })?; + publish_current_enrollment(current_enrollment, enrollment_ref); let request = build_remote_control_websocket_request( &remote_control_target.websocket_url, enrollment_ref, @@ -1415,6 +1382,7 @@ pub(super) async fn connect_remote_control_websocket( connect_options.app_server_client_name, enrollment, status_publisher, + current_enrollment, ) .await; } @@ -1429,6 +1397,7 @@ pub(super) async fn connect_remote_control_websocket( ) })? .clear_server_token(); + clear_current_enrollment(current_enrollment); return Err(io::Error::other(format!( "remote control websocket auth failed with HTTP {}; refreshing server token before reconnect", response.status() @@ -1453,6 +1422,7 @@ async fn clear_remote_control_enrollment( app_server_client_name: Option<&str>, enrollment: &mut Option, status_publisher: &RemoteControlStatusPublisher, + current_enrollment: &CurrentRemoteControlEnrollment, ) { if let Err(clear_err) = update_persisted_remote_control_enrollment( Some(state_db), @@ -1467,6 +1437,7 @@ async fn clear_remote_control_enrollment( } *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); + clear_current_enrollment(current_enrollment); } async fn enroll_remote_control_server_if_missing( @@ -1534,52 +1505,6 @@ async fn enroll_remote_control_server_if_missing( Ok(()) } -async fn recover_remote_control_auth( - auth_recovery: &mut UnauthorizedRecovery, - auth_change_rx: &mut watch::Receiver, -) -> bool { - if !auth_recovery.has_next() { - return false; - } - - let mode = auth_recovery.mode_name(); - let step = auth_recovery.step_name(); - let auth_change_revision_before_recovery = *auth_change_rx.borrow(); - match auth_recovery.next().await { - Ok(step_result) => { - if step_result.auth_state_changed() == Some(true) { - mark_recovery_auth_change_seen( - auth_change_rx, - auth_change_revision_before_recovery, - ); - } - info!( - "remote control websocket auth recovery succeeded: mode={mode}, step={step}, auth_state_changed={:?}", - step_result.auth_state_changed() - ); - true - } - Err(err) => { - warn!("remote control websocket auth recovery failed: mode={mode}, step={step}: {err}"); - false - } - } -} - -fn mark_recovery_auth_change_seen( - auth_change_rx: &mut watch::Receiver, - auth_change_revision_before_recovery: u64, -) { - let auth_change_revision_after_recovery = *auth_change_rx.borrow(); - if auth_change_revision_after_recovery == auth_change_revision_before_recovery.wrapping_add(1) { - // Recovery updated the same watch that wakes the outer reconnect - // loop. Mark only that single revision seen; if more revisions - // arrived while recovery was in flight, leave them pending so the - // reconnect loop still reacts to the later external auth change. - auth_change_rx.borrow_and_update(); - } -} - fn format_remote_control_websocket_connect_error( websocket_url: &str, err: &tungstenite::Error, @@ -1606,6 +1531,7 @@ mod tests { use super::*; use crate::outgoing_message::OutgoingMessage; use crate::transport::remote_control::ServerEvent; + use crate::transport::remote_control::auth::mark_recovery_auth_change_seen; use crate::transport::remote_control::protocol::StreamId; use crate::transport::remote_control::protocol::normalize_remote_control_url; use chrono::Utc; @@ -1647,6 +1573,8 @@ mod tests { fn remote_control_enrollment(remote_control_token: Option<&str>) -> RemoteControlEnrollment { RemoteControlEnrollment { + remote_control_target: normalize_remote_control_url("http://localhost/backend-api/") + .expect("target should normalize"), account_id: "account_id".to_string(), environment_id: "env_test".to_string(), server_id: "srv_e_test".to_string(), @@ -1657,6 +1585,10 @@ mod tests { } } + fn test_current_enrollment() -> CurrentRemoteControlEnrollment { + Arc::new(std::sync::Mutex::new(None)) + } + #[test] fn next_reconnect_delay_resets_after_cap() { let mut reconnect_attempt = 9; @@ -1810,6 +1742,7 @@ mod tests { let mut enrollment = Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, ))); + let current_enrollment = test_current_enrollment(); let (status_publisher, status_rx) = remote_control_status_channel(); let err = match connect_remote_control_websocket( @@ -1828,6 +1761,7 @@ mod tests { app_server_client_name: None, }, &status_publisher, + ¤t_enrollment, ) .await { @@ -1837,6 +1771,12 @@ mod tests { server_task.await.expect("server task should succeed"); assert_eq!(err.to_string(), expected_error); + assert!( + current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_some() + ); assert_eq!( status_rx.borrow().clone(), RemoteControlStatusChangedNotification { @@ -1864,6 +1804,7 @@ mod tests { let mut enrollment = Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, ))); + let current_enrollment = test_current_enrollment(); let (status_publisher, status_rx) = remote_control_status_channel(); let server_task = tokio::spawn(async move { @@ -1891,6 +1832,7 @@ mod tests { app_server_client_name: None, }, &status_publisher, + ¤t_enrollment, ) .await .expect_err("unauthorized response should fail the websocket connect"); @@ -1909,11 +1851,14 @@ mod tests { err.to_string(), "remote control websocket auth failed with HTTP 401 Unauthorized; refreshing server token before reconnect" ); - assert_eq!( - enrollment, - Some(remote_control_enrollment( - /*remote_control_token*/ None - )) + let mut expected_enrollment = remote_control_enrollment(/*remote_control_token*/ None); + expected_enrollment.remote_control_target = remote_control_target; + assert_eq!(enrollment, Some(expected_enrollment)); + assert!( + current_enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_none() ); } @@ -1976,6 +1921,7 @@ mod tests { app_server_client_name: None, }, &status_publisher, + &test_current_enrollment(), ) .await .expect_err("unauthorized enrollment should fail the websocket connect"); @@ -2070,6 +2016,7 @@ mod tests { app_server_client_name: None, }, &status_publisher, + &test_current_enrollment(), ) .await .expect_err("unauthorized refresh should fail the websocket connect"); @@ -2135,6 +2082,7 @@ mod tests { app_server_client_name: None, }, &status_publisher, + &test_current_enrollment(), ) .await .expect_err("missing sqlite state db should fail remote control"); @@ -2185,6 +2133,7 @@ mod tests { app_server_client_name: None, }, &status_publisher, + &test_current_enrollment(), ) .await .expect_err("missing auth should fail remote control"); @@ -2236,6 +2185,7 @@ mod tests { RemoteControlChannels { transport_event_tx, status_publisher, + current_enrollment: test_current_enrollment(), }, shutdown_token, enabled_rx, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 29acc51983..dc02ff8c04 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -210,6 +210,9 @@ Example with notification opt-out: - `remoteControl/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. The caller is responsible for persisting the desired setting outside app-server. - `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices. - `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. +- `remoteControl/pairing/start` — experimental; start a short-lived remote-control pairing artifact for the current app-server process. Pass `manualCode: true` to also request a manual pairing code. Returns `pairingCode`, `manualPairingCode`, `environmentId`, and Unix-seconds `expiresAt`; app-server intentionally does not expose the backend `serverId`. +- `remoteControl/client/list` — experimental; list controller devices granted access to an environment. Pass `environmentId` and optional `cursor`, `limit`, and `order`; returns picker-oriented client metadata plus `nextCursor`. This signed-in account-management operation works while the local relay is disabled or unenrolled. +- `remoteControl/client/revoke` — experimental; revoke one controller device's grant for an environment. Pass `environmentId` and `clientId`; returns an empty object. This signed-in account-management operation works while the local relay is disabled or unenrolled. - `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. - `skills/config/write` — write user-level skill config by name or absolute path. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). @@ -1378,7 +1381,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 { @@ -1388,6 +1391,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": { @@ -1715,6 +1719,20 @@ The server also emits `app/list/updated` notifications whenever either source (a } ``` +Connected apps may override the thread's approval reviewer in `config.toml`. +When omitted, the app inherits the top-level `approvals_reviewer` value: + +```toml +approvals_reviewer = "auto_review" + +[apps.demo-app] +approvals_reviewer = "user" +``` + +Setting the app value to `"user"` routes its approval prompts to the user +instead of Guardian; setting it to `"auto_review"` opts that app into Guardian +review when allowed by configuration requirements. + Invoke an app by inserting `$` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://` path rather than guessing by name. Plugins use the same `mention` item shape, but with `plugin://@` paths from `plugin/installed` or `plugin/list`. Example: @@ -1759,8 +1777,8 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`. - `account/logout` — sign out; triggers `account/updated`. - `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available. -- `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify). -- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. +- `account/rateLimits/read` — fetch ChatGPT rate limits and an optional effective monthly credit limit; updates arrive via `account/rateLimits/updated` (notify). +- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. - `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit. - `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`. - `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes for a loaded thread; payload includes `{ name, status, error }` where `status` is `starting`, `ready`, `failed`, or `cancelled`. @@ -1864,6 +1882,7 @@ Field notes: - `windowDurationMins` is the quota window length. - `resetsAt` is a Unix timestamp (seconds) for the next reset. - `rateLimitReachedType` identifies the backend-classified limit state when one has been reached. +- `individualLimit` describes the effective monthly credit limit when available. In an `account/rateLimits/read` response, `null` means no monthly limit is available. In a sparse `account/rateLimits/updated` notification, nullable account metadata may be unavailable and does not clear a previously observed value. ### 8) Notify a workspace owner about a limit diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 56754868fc..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, @@ -3546,6 +3547,7 @@ mod tests { unlimited: false, balance: Some("5".to_string()), }), + individual_limit: None, plan_type: None, rate_limit_reached_type: 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 5c6e3de5a1..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] @@ -284,6 +283,7 @@ async fn write_value_supports_nested_app_paths() -> Result<()> { "app1".to_string(), AppConfig { enabled: false, + approvals_reviewer: None, destructive_enabled: None, open_world_enabled: None, default_tools_approval_mode: Some(AppToolApproval::Prompt), @@ -355,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 @@ -434,7 +434,7 @@ writable_roots = ["~/code"] tmp.path().to_path_buf(), vec![], loader_overrides, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let response = service @@ -474,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 @@ -577,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 { @@ -617,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 @@ -647,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 @@ -707,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 @@ -764,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 @@ -820,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.rs b/codex-rs/app-server/src/message_processor.rs index 2dbd17a3d7..84f22809ee 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -917,6 +917,21 @@ impl MessageProcessor { .remote_control_processor .status_read() .map(|response| Some(response.into())), + ClientRequest::RemoteControlPairingStart { params, .. } => self + .remote_control_processor + .pairing_start(params) + .await + .map(|response| Some(response.into())), + ClientRequest::RemoteControlClientsList { params, .. } => self + .remote_control_processor + .clients_list(params) + .await + .map(|response| Some(response.into())), + ClientRequest::RemoteControlClientsRevoke { params, .. } => self + .remote_control_processor + .clients_revoke(params) + .await + .map(|response| Some(response.into())), ClientRequest::ConfigRequirementsRead { params: _, .. } => self .config_processor .config_requirements_read() 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/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index e8fffd5361..ac7e87741a 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -798,6 +798,7 @@ mod tests { }), secondary: None, credits: None, + individual_limit: None, plan_type: Some(PlanType::Plus), rate_limit_reached_type: None, }, @@ -818,6 +819,7 @@ mod tests { }, "secondary": null, "credits": null, + "individualLimit": null, "planType": "plus", "rateLimitReachedType": null } 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/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 4171887cdd..2335694856 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -662,6 +662,7 @@ impl PluginRequestProcessor { &remote_plugin_service_config, auth.as_ref(), &remote_sources, + /*global_catalog_cache_path*/ Some(config.codex_home.as_path()), ) .await { diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor.rs b/codex-rs/app-server/src/request_processors/remote_control_processor.rs index 2fceeb5030..804339c17a 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor.rs @@ -3,9 +3,16 @@ use crate::error_code::invalid_request; use crate::transport::RemoteControlHandle; use crate::transport::RemoteControlUnavailable; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsListResponse; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; +use codex_app_server_protocol::RemoteControlClientsRevokeResponse; use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; +use codex_app_server_protocol::RemoteControlPairingStartParams; +use codex_app_server_protocol::RemoteControlPairingStartResponse; use codex_app_server_protocol::RemoteControlStatusReadResponse; +use std::io; #[derive(Clone)] pub(crate) struct RemoteControlRequestProcessor { @@ -42,6 +49,36 @@ impl RemoteControlRequestProcessor { }) } + pub(crate) async fn pairing_start( + &self, + params: RemoteControlPairingStartParams, + ) -> Result { + self.handle()? + .start_pairing(params) + .await + .map_err(map_pairing_start_error) + } + + pub(crate) async fn clients_list( + &self, + params: RemoteControlClientsListParams, + ) -> Result { + self.handle()? + .list_clients(params) + .await + .map_err(map_client_management_error) + } + + pub(crate) async fn clients_revoke( + &self, + params: RemoteControlClientsRevokeParams, + ) -> Result { + self.handle()? + .revoke_client(params) + .await + .map_err(map_client_management_error) + } + fn handle(&self) -> Result<&RemoteControlHandle, JSONRPCErrorError> { self.remote_control_handle .as_ref() @@ -52,3 +89,24 @@ impl RemoteControlRequestProcessor { fn map_unavailable(err: RemoteControlUnavailable) -> JSONRPCErrorError { invalid_request(err.to_string()) } + +fn map_pairing_start_error(err: io::Error) -> JSONRPCErrorError { + if err.kind() == io::ErrorKind::InvalidInput { + invalid_request(err.to_string()) + } else { + internal_error(err.to_string()) + } +} + +fn map_client_management_error(err: io::Error) -> JSONRPCErrorError { + match err.kind() { + io::ErrorKind::InvalidInput + | io::ErrorKind::NotFound + | io::ErrorKind::PermissionDenied + | io::ErrorKind::WouldBlock => invalid_request(err.to_string()), + _ => internal_error(err.to_string()), + } +} + +#[cfg(test)] +mod remote_control_processor_tests; diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs new file mode 100644 index 0000000000..4381e48959 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs @@ -0,0 +1,79 @@ +use super::*; +use crate::error_code::INTERNAL_ERROR_CODE; +use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn pairing_start_returns_internal_error_when_remote_control_is_unavailable() { + let err = RemoteControlRequestProcessor::new(/*remote_control_handle*/ None) + .pairing_start(RemoteControlPairingStartParams::default()) + .await + .expect_err("missing remote control should fail pairing"); + + assert_eq!( + err, + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + data: None, + message: "remote control is unavailable for this app-server".to_string(), + } + ); +} + +#[test] +fn pairing_start_maps_invalid_input_to_invalid_request() { + assert_eq!( + map_pairing_start_error(io::Error::new( + io::ErrorKind::InvalidInput, + "remote control pairing is unavailable", + )), + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + data: None, + message: "remote control pairing is unavailable".to_string(), + } + ); +} + +#[test] +fn pairing_start_maps_backend_failures_to_internal_error() { + assert_eq!( + map_pairing_start_error(io::Error::other("remote control pairing failed")), + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + data: None, + message: "remote control pairing failed".to_string(), + } + ); +} + +#[test] +fn client_management_maps_user_actionable_errors_to_invalid_request() { + for kind in [ + io::ErrorKind::InvalidInput, + io::ErrorKind::NotFound, + io::ErrorKind::PermissionDenied, + io::ErrorKind::WouldBlock, + ] { + assert_eq!( + map_client_management_error(io::Error::new(kind, "client management unavailable")), + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + data: None, + message: "client management unavailable".to_string(), + } + ); + } +} + +#[test] +fn client_management_maps_backend_failures_to_internal_error() { + assert_eq!( + map_client_management_error(io::Error::other("client management failed")), + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + data: None, + message: "client management failed".to_string(), + } + ); +} 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/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/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 56a9378662..666f6ed7cd 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -54,6 +54,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, } } diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 63681382ea..6d446e1c42 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -194,6 +194,7 @@ fn create_fake_rollout_with_source_and_parent_thread_id( base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }; let payload = serde_json::to_value(SessionMetaLine { meta, @@ -279,6 +280,7 @@ pub fn create_fake_rollout_with_text_elements( base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }; let payload = serde_json::to_value(SessionMetaLine { meta, diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index 3a3787d234..ec4b30d92f 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -67,6 +67,9 @@ use codex_app_server_protocol::ProcessKillParams; use codex_app_server_protocol::ProcessResizePtyParams; use codex_app_server_protocol::ProcessSpawnParams; use codex_app_server_protocol::ProcessWriteStdinParams; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; +use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; @@ -643,6 +646,35 @@ impl TestAppServer { .await } + /// Send a `remoteControl/pairing/start` JSON-RPC request. + pub async fn send_remote_control_pairing_start_request( + &mut self, + params: RemoteControlPairingStartParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("remoteControl/pairing/start", params) + .await + } + + /// Send a `remoteControl/client/list` JSON-RPC request. + pub async fn send_remote_control_clients_list_request( + &mut self, + params: RemoteControlClientsListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("remoteControl/client/list", params).await + } + + /// Send a `remoteControl/client/revoke` JSON-RPC request. + pub async fn send_remote_control_clients_revoke_request( + &mut self, + params: RemoteControlClientsRevokeParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("remoteControl/client/revoke", params) + .await + } + /// Send an `app/list` JSON-RPC request. pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 109999d90e..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; @@ -128,6 +128,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), + multi_agent_version: None, metadata: ThreadPersistenceMetadata { cwd: None, model_provider: "test-provider".to_string(), @@ -149,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/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index dbcc12084a..649c4fe8e0 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -5,6 +5,7 @@ use app_test_support::test_tmp_path_buf; use app_test_support::to_response; use codex_app_server_protocol::AppConfig; use codex_app_server_protocol::AppToolApproval; +use codex_app_server_protocol::ApprovalsReviewer; use codex_app_server_protocol::AppsConfig; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ConfigBatchWriteParams; @@ -333,6 +334,7 @@ async fn config_read_includes_apps() -> Result<()> { r#" [apps.app1] enabled = false +approvals_reviewer = "user" destructive_enabled = false default_tools_approval_mode = "prompt" "#, @@ -368,6 +370,7 @@ default_tools_approval_mode = "prompt" "app1".to_string(), AppConfig { enabled: false, + approvals_reviewer: Some(ApprovalsReviewer::User), destructive_enabled: Some(false), open_world_enabled: None, default_tools_approval_mode: Some(AppToolApproval::Prompt), @@ -384,6 +387,16 @@ default_tools_approval_mode = "prompt" profile: None, } ); + assert_eq!( + origins + .get("apps.app1.approvals_reviewer") + .expect("origin") + .name, + ConfigLayerSource::User { + file: user_file.clone(), + profile: None, + } + ); assert_eq!( origins .get("apps.app1.destructive_enabled") diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs new file mode 100644 index 0000000000..924c6c58ba --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -0,0 +1,307 @@ +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::TestAppServer; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::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"; + +#[derive(Clone, Copy)] +enum ImagegenTestMode { + Direct, + CodeModeOnly, +} + +// 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(), ImagegenTestMode::Direct)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + start_image_generation_turn(&mut mcp).await?; + + 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(()) +} + +#[cfg_attr(windows, ignore = "covered by Linux and macOS CI")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn standalone_image_generation_is_callable_from_code_mode_only() -> Result<()> { + let call_id = "code-mode-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_custom_tool_call( + call_id, + "exec", + r#" +const result = await tools.image_gen__imagegen({ + action: "generate", + prompt: "paint a blue whale", +}); +image(result); +"#, + ), + 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(), + ImagegenTestMode::CodeModeOnly, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + start_image_generation_turn(&mut mcp).await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + assert!(requests[0].body_contains_text("image_gen__imagegen")); + let output = requests[1].custom_tool_call_output(call_id); + assert_eq!( + output["output"][1], + json!({ + "type": "input_image", + "image_url": format!("data:image/png;base64,{RESULT}"), + "detail": "high", + }) + ); + assert_eq!(output["output"].as_array().map(Vec::len), Some(2)); + + Ok(()) +} + +async fn start_image_generation_turn(mcp: &mut TestAppServer) -> Result<()> { + 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)?; + + Ok(()) +} + +async fn wait_for_image_generation_completed( + mcp: &mut TestAppServer, +) -> 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, + mode: ImagegenTestMode, +) -> std::io::Result<()> { + let code_mode_only = match mode { + ImagegenTestMode::Direct => "", + ImagegenTestMode::CodeModeOnly => "code_mode_only = true", + }; + 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 +{code_mode_only} + +[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/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/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 1605983705..9aef40aade 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 imagegen_extension; mod initialize; mod marketplace_add; mod marketplace_remove; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index ea9e5f4f35..0df7c82eef 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1900,6 +1900,23 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - "project management".to_string() ] ); + let cache_files = std::fs::read_dir(codex_home.path().join("cache/remote_plugin_catalog"))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::, _>>()?; + assert_eq!(cache_files.len(), 1); + let cached_catalog: serde_json::Value = + serde_json::from_slice(&std::fs::read(&cache_files[0])?)?; + assert_eq!(cached_catalog["schema_version"], serde_json::json!(1)); + let cached_plugin_ids = cached_catalog["plugins"] + .as_array() + .expect("cached plugins should be an array") + .iter() + .map(|plugin| plugin["id"].as_str().expect("cached plugin id").to_string()) + .collect::>(); + assert_eq!( + cached_plugin_ids, + vec!["plugins~Plugin_00000000000000000000000000000000".to_string()] + ); assert_eq!(response.featured_plugin_ids, Vec::::new()); assert!( !server diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 0eca044991..1bb93db819 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -15,6 +15,7 @@ use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse; +use codex_app_server_protocol::SpendControlLimitSnapshot; use codex_config::types::AuthCredentialsStoreMode; use codex_protocol::account::PlanType as AccountPlanType; use pretty_assertions::assert_eq; @@ -128,6 +129,19 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { "rate_limit_reached_type": { "type": "workspace_member_usage_limit_reached", }, + "spend_control": { + "reached": false, + "individual_limit": { + "source": "workspace_spend_controls", + "limit": "25000", + "used": "8000", + "remaining": "17000", + "used_percent": 32, + "remaining_percent": 68, + "reset_after_seconds": 43200, + "reset_at": secondary_reset_timestamp, + } + }, "additional_rate_limits": [ { "limit_name": "codex_other", @@ -183,6 +197,12 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { resets_at: Some(secondary_reset_timestamp), }), credits: None, + individual_limit: Some(SpendControlLimitSnapshot { + limit: "25000".to_string(), + used: "8000".to_string(), + remaining_percent: 68, + resets_at: secondary_reset_timestamp, + }), plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), }, @@ -204,6 +224,12 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { resets_at: Some(secondary_reset_timestamp), }), credits: None, + individual_limit: Some(SpendControlLimitSnapshot { + limit: "25000".to_string(), + used: "8000".to_string(), + remaining_percent: 68, + resets_at: secondary_reset_timestamp, + }), plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: Some( RateLimitReachedType::WorkspaceMemberUsageLimitReached, @@ -222,6 +248,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { }), secondary: None, credits: None, + individual_limit: None, plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: None, }, diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index d15f2d4187..b5384d3bad 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -8,15 +8,24 @@ use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RemoteControlClient; +use codex_app_server_protocol::RemoteControlClientsListOrder; +use codex_app_server_protocol::RemoteControlClientsListParams; +use codex_app_server_protocol::RemoteControlClientsListResponse; +use codex_app_server_protocol::RemoteControlClientsRevokeParams; +use codex_app_server_protocol::RemoteControlClientsRevokeResponse; use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; +use codex_app_server_protocol::RemoteControlPairingStartParams; +use codex_app_server_protocol::RemoteControlPairingStartResponse; use codex_app_server_protocol::RemoteControlStatusReadResponse; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncWriteExt; use tokio::io::BufReader; use tokio::net::TcpListener; use tokio::net::TcpStream; @@ -125,27 +134,192 @@ async fn remote_control_status_read_returns_connecting_status_after_enable() -> Ok(()) } +#[tokio::test] +async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()> { + let codex_home = TempDir::new()?; + let mut backend = PairingRemoteControlBackend::start(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_remote_control_enable_request().await?; + let _: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!( + timeout(DEFAULT_TIMEOUT, backend.wait_for_enroll_request()).await??, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_matching_notification( + "remoteControl/status/changed enrolled", + |notification| { + notification.method == "remoteControl/status/changed" + && notification + .params + .as_ref() + .and_then(|params| params.get("environmentId")) + .and_then(serde_json::Value::as_str) + == Some("environment-id") + }, + ), + ) + .await??; + + let request_id = mcp + .send_remote_control_pairing_start_request(RemoteControlPairingStartParams { + manual_code: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(response.result.get("serverId"), None); + let received: RemoteControlPairingStartResponse = to_response(response)?; + + assert_eq!( + received, + RemoteControlPairingStartResponse { + pairing_code: "pairing-code".to_string(), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + environment_id: "environment-id".to_string(), + expires_at: 33_336_362_096, + } + ); + Ok(()) +} + +#[tokio::test] +async fn remote_control_client_management_works_while_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let mut backend = ClientManagementRemoteControlBackend::start(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_remote_control_clients_list_request(RemoteControlClientsListParams { + environment_id: "environment-id".to_string(), + cursor: Some("cursor-id".to_string()), + limit: Some(10), + order: Some(RemoteControlClientsListOrder::Desc), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlClientsListResponse = to_response(response)?; + assert_eq!( + received, + RemoteControlClientsListResponse { + data: vec![RemoteControlClient { + client_id: "client-id".to_string(), + display_name: Some("Anton Phone".to_string()), + device_type: Some("phone".to_string()), + platform: Some("ios".to_string()), + os_version: Some("19.0".to_string()), + device_model: Some("iPhone".to_string()), + app_version: Some("1.2.3".to_string()), + last_seen_at: Some(1_772_694_000), + }], + next_cursor: Some("next-cursor".to_string()), + } + ); + + let request_id = mcp + .send_remote_control_clients_revoke_request(RemoteControlClientsRevokeParams { + environment_id: "environment-id".to_string(), + client_id: "client-id".to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlClientsRevokeResponse = to_response(response)?; + assert_eq!(received, RemoteControlClientsRevokeResponse {}); + assert_eq!( + timeout(DEFAULT_TIMEOUT, backend.wait_for_requests()).await??, + vec![ + "GET /backend-api/wham/remote/control/environments/environment-id/clients?cursor=cursor-id&limit=10&order=desc HTTP/1.1".to_string(), + "DELETE /backend-api/wham/remote/control/environments/environment-id/clients/client-id HTTP/1.1".to_string(), + ] + ); + Ok(()) +} + struct BlockingRemoteControlBackend { enroll_request_rx: Option>>, server_task: JoinHandle<()>, } +struct ClientManagementRemoteControlBackend { + requests_rx: Option>>>, + server_task: JoinHandle<()>, +} + +impl ClientManagementRemoteControlBackend { + async fn start(codex_home: &std::path::Path) -> Result { + let listener = configured_remote_control_listener(codex_home).await?; + let (requests_tx, requests_rx) = oneshot::channel(); + let server_task = tokio::spawn(async move { + let result = async { + let list_request = read_http_request(&listener).await?; + let list_request_line = list_request.request_line; + respond_with_json( + list_request.reader.into_inner(), + serde_json::json!({ + "items": [{ + "client_id": "client-id", + "account_user_id": "user-id", + "enrollment_status": "enrolled_device_key", + "display_name": "Anton Phone", + "device_type": "phone", + "platform": "ios", + "os_version": "19.0", + "device_model": "iPhone", + "app_version": "1.2.3", + "last_seen_at": "2026-03-05T07:00:00Z", + "last_seen_city": "San Francisco", + }], + "cursor": "next-cursor", + }), + ) + .await?; + + let revoke_request = read_http_request(&listener).await?; + let revoke_request_line = revoke_request.request_line; + respond_with_status(revoke_request.reader.into_inner(), "204 No Content", "") + .await?; + + Ok(vec![list_request_line, revoke_request_line]) + } + .await; + let _ = requests_tx.send(result); + }); + Ok(Self { + requests_rx: Some(requests_rx), + server_task, + }) + } + + async fn wait_for_requests(&mut self) -> Result> { + self.requests_rx + .take() + .context("requests should only be awaited once")? + .await? + } +} + impl BlockingRemoteControlBackend { async fn start(codex_home: &std::path::Path) -> Result { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let remote_control_url = format!("http://{}/backend-api/", listener.local_addr()?); - write_mock_responses_config_toml_with_chatgpt_base_url( - codex_home, - &remote_control_url, - &remote_control_url, - )?; - write_chatgpt_auth( - codex_home, - ChatGptAuthFixture::new("chatgpt-token") - .account_id("account_id") - .chatgpt_account_id("account_id"), - AuthCredentialsStoreMode::File, - )?; + let listener = configured_remote_control_listener(codex_home).await?; let (enroll_request_tx, enroll_request_rx) = oneshot::channel(); let server_task = tokio::spawn(async move { @@ -175,19 +349,125 @@ impl BlockingRemoteControlBackend { } } +struct PairingRemoteControlBackend { + enroll_request_rx: Option>>, + server_task: JoinHandle<()>, +} + +impl PairingRemoteControlBackend { + async fn start(codex_home: &std::path::Path) -> Result { + let listener = configured_remote_control_listener(codex_home).await?; + let (enroll_request_tx, enroll_request_rx) = oneshot::channel(); + let server_task = tokio::spawn(async move { + let mut enroll_request_tx = Some(enroll_request_tx); + let result = async { + let enroll_request = read_http_request(&listener).await?; + if let Some(enroll_request_tx) = enroll_request_tx.take() { + let _ = enroll_request_tx.send(Ok(enroll_request.request_line.clone())); + } + respond_with_json( + enroll_request.reader.into_inner(), + serde_json::json!({ + "server_id": "server-id", + "environment_id": "environment-id", + "remote_control_token": "remote-control-token", + "expires_at": "3026-05-22T12:34:56Z", + }), + ) + .await?; + + let _websocket_request = read_http_request(&listener).await?; + let pair_http_request = read_http_request(&listener).await?; + respond_with_json( + pair_http_request.reader.into_inner(), + serde_json::json!({ + "pairing_code": "pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": "server-id", + "environment_id": "environment-id", + "expires_at": "3026-05-22T12:34:56Z", + }), + ) + .await?; + std::future::pending::<()>().await; + Ok::<(), anyhow::Error>(()) + } + .await; + + if let Err(err) = result { + let err = err.to_string(); + if let Some(enroll_request_tx) = enroll_request_tx { + let _ = enroll_request_tx.send(Err(anyhow::anyhow!(err))); + } + } + }); + + Ok(Self { + enroll_request_rx: Some(enroll_request_rx), + server_task, + }) + } + + async fn wait_for_enroll_request(&mut self) -> Result { + self.enroll_request_rx + .take() + .context("enroll request should only be awaited once")? + .await? + } +} + +impl Drop for PairingRemoteControlBackend { + fn drop(&mut self) { + self.server_task.abort(); + } +} + impl Drop for BlockingRemoteControlBackend { fn drop(&mut self) { self.server_task.abort(); } } +impl Drop for ClientManagementRemoteControlBackend { + fn drop(&mut self) { + self.server_task.abort(); + } +} + +struct HttpRequest { + request_line: String, + reader: BufReader, +} + +async fn configured_remote_control_listener(codex_home: &std::path::Path) -> Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let remote_control_url = format!("http://{}/backend-api/", listener.local_addr()?); + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home, + &remote_control_url, + &remote_control_url, + )?; + write_chatgpt_auth( + codex_home, + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account_id") + .chatgpt_account_id("account_id"), + AuthCredentialsStoreMode::File, + )?; + Ok(listener) +} + async fn read_enroll_request(listener: TcpListener) -> Result<(String, BufReader)> { + let request = read_http_request(&listener).await?; + Ok((request.request_line, request.reader)) +} + +async fn read_http_request(listener: &TcpListener) -> Result { let (stream, _) = listener.accept().await?; let mut reader = BufReader::new(stream); let mut request_line = String::new(); reader.read_line(&mut request_line).await?; - loop { let mut line = String::new(); reader.read_line(&mut line).await?; @@ -196,5 +476,36 @@ async fn read_enroll_request(listener: TcpListener) -> Result<(String, BufReader } } - Ok((request_line.trim_end().to_string(), reader)) + Ok(HttpRequest { + request_line: request_line.trim_end().to_string(), + reader, + }) +} + +async fn respond_with_json(stream: TcpStream, body: serde_json::Value) -> Result<()> { + let body = body.to_string(); + let mut stream = stream; + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await?; + Ok(()) +} + +async fn respond_with_status(mut stream: TcpStream, status: &str, body: &str) -> Result<()> { + stream + .write_all( + format!( + "HTTP/1.1 {status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await?; + Ok(()) } 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 f89c9ec5e2..afb1ca0ed1 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -30,6 +30,10 @@ use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use codex_config::types::AuthCredentialsStoreMode; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_protocol::protocol::MultiAgentVersion; +use codex_protocol::protocol::RolloutItem; +use codex_rollout::append_rollout_item_to_path; +use codex_rollout::read_session_meta_line; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -82,6 +86,9 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { "expected original rollout to exist at {}", original_path.display() ); + let mut session_meta = read_session_meta_line(&original_path).await?; + session_meta.meta.multi_agent_version = Some(MultiAgentVersion::V1); + append_rollout_item_to_path(&original_path, &RolloutItem::SessionMeta(session_meta)).await?; let original_contents = std::fs::read_to_string(&original_path)?; let mut mcp = TestAppServer::new(codex_home.path()).await?; @@ -503,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") @@ -587,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 1540a84b58..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, @@ -1362,6 +1362,7 @@ async fn seed_pathless_store_thread( thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), + multi_agent_version: None, metadata: ThreadPersistenceMetadata { cwd: None, model_provider: "test-provider".to_string(), 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 ff5d537225..d18a405164 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -63,6 +63,8 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ImageGenerationEndEvent; use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::McpToolCallEndEvent; +use codex_protocol::protocol::MultiAgentVersion; +use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource as RolloutSessionSource; @@ -74,6 +76,8 @@ use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; +use codex_rollout::append_rollout_item_to_path; +use codex_rollout::read_session_meta_line; use codex_state::StateRuntime; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::responses; @@ -1805,6 +1809,7 @@ stream_max_retries = 0 base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }; std::fs::write( &rollout_path, @@ -2013,7 +2018,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; - let rollout = setup_rollout_fixture(codex_home.path(), &server.uri())?; + let rollout = setup_rollout_fixture(codex_home.path(), &server.uri()).await?; let thread_id = rollout.conversation_id.clone(); let mut mcp = TestAppServer::new(codex_home.path()).await?; @@ -2405,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"))?; @@ -3051,7 +3086,7 @@ async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Re async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; - let rollout = setup_rollout_fixture(codex_home.path(), &server.uri())?; + let rollout = setup_rollout_fixture(codex_home.path(), &server.uri()).await?; create_config_toml_with_required_broken_mcp(codex_home.path(), &server.uri())?; let mut mcp = TestAppServer::new(codex_home.path()).await?; @@ -3086,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") @@ -3166,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, @@ -3614,7 +3649,7 @@ struct RolloutFixture { before_modified: std::time::SystemTime, } -fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result { +async fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result { create_config_toml(codex_home, server_uri)?; let preview = "Saved user message"; @@ -3631,6 +3666,10 @@ fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result 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 70a9ff23de..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; @@ -215,6 +215,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), + multi_agent_version: None, metadata: ThreadPersistenceMetadata { cwd: None, model_provider: "test-provider".to_string(), @@ -246,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/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/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 2f6ae4653c..87940f1182 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -160,12 +160,13 @@ pub fn arg0_dispatch() -> Option { /// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we: /// /// 1. Load `.env` values from `~/.codex/.env` before creating any threads. -/// 2. Construct a Tokio multi-thread runtime. -/// 3. Capture the current executable path and derive the +/// 2. Spawn a main runtime thread with a controlled stack size. +/// 3. Construct a Tokio multi-thread runtime. +/// 4. Capture the current executable path and derive the /// `codex-linux-sandbox` helper path (falling back to the current /// executable if needed) so children can re-invoke the sandbox when running /// on Linux. -/// 4. Execute the provided async `main_fn` inside that runtime, forwarding any +/// 5. Execute the provided async `main_fn` inside that runtime, forwarding any /// error. Note that `main_fn` receives [`Arg0DispatchPaths`], which /// contains the helper executable paths needed to construct /// [`codex_core::config::Config`]. @@ -174,22 +175,33 @@ pub fn arg0_dispatch() -> Option { /// in this workspace that depends on these helper CLIs. pub fn arg0_dispatch_or_else(main_fn: F) -> anyhow::Result<()> where - F: FnOnce(Arg0DispatchPaths) -> Fut, + F: FnOnce(Arg0DispatchPaths) -> Fut + Send + 'static, Fut: Future>, { // Retain the TempDir so it exists for the lifetime of the invocation of // this executable. Admittedly, we could invoke `keep()` on it, but it // would be nice to avoid leaving temporary directories behind, if possible. let path_entry_guard = arg0_dispatch(); + let current_exe = std::env::current_exe().ok(); - // Regular invocation – create a Tokio runtime and execute the provided - // async entry-point. - let runtime = build_runtime()?; - runtime.block_on(run_main_with_arg0_guard( - path_entry_guard, - std::env::current_exe().ok(), - main_fn, - )) + // Regular invocation. Run the async entry point on a thread with the same + // stack budget as Tokio workers; `Runtime::block_on` otherwise runs the + // top-level future on the caller's OS stack. + let handle = std::thread::Builder::new() + .name("codex-main".to_string()) + .stack_size(TOKIO_WORKER_STACK_SIZE_BYTES) + .spawn(move || { + let runtime = build_runtime()?; + runtime.block_on(run_main_with_arg0_guard( + path_entry_guard, + current_exe, + main_fn, + )) + })?; + match handle.join() { + Ok(result) => result, + Err(payload) => std::panic::resume_unwind(payload), + } } async fn run_main_with_arg0_guard( diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 8a3adbdd51..8ddf2fc89d 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::ListCredentialRoutesResponse; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; @@ -17,6 +16,7 @@ use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::RateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; +use codex_protocol::protocol::SpendControlLimitSnapshot; use reqwest::StatusCode; use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; @@ -398,23 +398,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) - } - pub async fn list_credential_routes(&self) -> Result { let url = self.credential_routes_url(); let req = self.http.get(&url).headers(self.headers()); @@ -432,7 +415,6 @@ impl Client { pub fn credential_routes_proxy_auth_headers(&self) -> HeaderMap { self.auth_headers() } - /// Fetch the selected cloud-managed config bundle from codex-backend. /// /// `GET /api/codex/config/bundle` (Codex API style) or @@ -494,11 +476,17 @@ impl Client { .rate_limit_reached_type .flatten() .and_then(|details| Self::map_rate_limit_reached_type(details.kind)); + let individual_limit = payload + .spend_control + .flatten() + .and_then(|details| details.individual_limit.flatten()) + .map(|details| Self::map_individual_limit(*details)); let mut snapshots = vec![Self::make_rate_limit_snapshot( Some("codex".to_string()), /*limit_name*/ None, payload.rate_limit.flatten().map(|details| *details), payload.credits.flatten().map(|details| *details), + individual_limit, plan_type, rate_limit_reached_type, )]; @@ -509,6 +497,7 @@ impl Client { Some(details.limit_name), details.rate_limit.flatten().map(|rate_limit| *rate_limit), /*credits*/ None, + /*individual_limit*/ None, plan_type, /*rate_limit_reached_type*/ None, ) @@ -522,6 +511,7 @@ impl Client { limit_name: Option, rate_limit: Option, credits: Option, + individual_limit: Option, plan_type: Option, rate_limit_reached_type: Option, ) -> RateLimitSnapshot { @@ -538,6 +528,7 @@ impl Client { primary, secondary, credits: Self::map_credits(credits), + individual_limit, plan_type, rate_limit_reached_type, } @@ -613,6 +604,17 @@ impl Client { }) } + fn map_individual_limit( + details: crate::types::SpendControlLimitDetails, + ) -> SpendControlLimitSnapshot { + SpendControlLimitSnapshot { + limit: details.limit, + used: details.used, + remaining_percent: details.remaining_percent, + resets_at: i64::from(details.reset_at), + } + } + fn map_plan_type(plan_type: crate::types::PlanType) -> AccountPlanType { match plan_type { crate::types::PlanType::Free => AccountPlanType::Free, @@ -707,6 +709,23 @@ mod tests { balance: Some(Some("9.99".to_string())), ..Default::default() }))), + spend_control: Some(Some(Box::new( + codex_backend_openapi_models::models::SpendControlStatusDetails { + reached: false, + individual_limit: Some(Some(Box::new( + crate::types::SpendControlLimitDetails { + source: None, + limit: "25000".to_string(), + used: "8000".to_string(), + remaining: "17000".to_string(), + used_percent: 32, + remaining_percent: 68, + reset_after_seconds: 3600, + reset_at: 789, + }, + ))), + }, + ))), rate_limit_reached_type: Some(Some(BackendRateLimitReachedType { kind: RateLimitReachedKind::WorkspaceMemberCreditsDepleted, })), @@ -738,6 +757,15 @@ mod tests { snapshots[0].rate_limit_reached_type, Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted) ); + assert_eq!( + snapshots[0].individual_limit, + Some(SpendControlLimitSnapshot { + limit: "25000".to_string(), + used: "8000".to_string(), + remaining_percent: 68, + resets_at: 789, + }) + ); assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other")); assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other")); @@ -746,6 +774,7 @@ mod tests { Some(70.0) ); assert_eq!(snapshots[1].credits, None); + assert_eq!(snapshots[1].individual_limit, None); assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro)); assert_eq!(snapshots[1].rate_limit_reached_type, None); } @@ -761,6 +790,7 @@ mod tests { rate_limit: None, }])), credits: None, + spend_control: None, rate_limit_reached_type: None, }; @@ -786,6 +816,7 @@ mod tests { }), secondary: None, credits: None, + individual_limit: None, plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: None, }, @@ -799,6 +830,7 @@ mod tests { }), secondary: None, credits: None, + individual_limit: None, plan_type: Some(AccountPlanType::Pro), rate_limit_reached_type: None, }, @@ -843,6 +875,7 @@ mod tests { plan_type: crate::types::PlanType::Plus, rate_limit: None, credits: None, + spend_control: None, additional_rate_limits: None, rate_limit_reached_type: Some(Some(BackendRateLimitReachedType { kind })), }; @@ -858,6 +891,7 @@ mod tests { plan_type: crate::types::PlanType::Plus, rate_limit: None, credits: None, + spend_control: None, additional_rate_limits: None, rate_limit_reached_type: None, }; diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 28a417a308..0fae9cb2d8 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::CredentialRouteAuthType; pub use types::DeliveredConfigToml; pub use types::DeliveredRequirementsToml; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 2cac1ad688..3aa20b6e8f 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; @@ -10,6 +9,7 @@ pub use codex_backend_openapi_models::models::RateLimitReachedKind; pub use codex_backend_openapi_models::models::RateLimitStatusDetails; pub use codex_backend_openapi_models::models::RateLimitStatusPayload; pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot; +pub use codex_backend_openapi_models::models::SpendControlLimitDetails; pub use codex_backend_openapi_models::models::TaskListItem; use serde::Deserialize; diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index f0058851d3..06d01741c9 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -19,11 +19,11 @@ use codex_protocol::config_types::SandboxMode; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_sandboxing::landlock::allow_network_for_proxy; use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_permission_profile; +use codex_sandboxing::prepare_managed_network_child; #[cfg(target_os = "macos")] use codex_sandboxing::seatbelt::CreateSeatbeltCommandArgsParams; #[cfg(target_os = "macos")] use codex_sandboxing::seatbelt::create_seatbelt_command_args; -use codex_sandboxing::with_managed_mitm_ca_readable_root; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; use tokio::process::Child; @@ -216,7 +216,7 @@ async fn run_command_under_sandbox( #[cfg(target_os = "windows")] let workspace_roots = config.effective_workspace_roots(); - let env = create_env( + let mut env = create_env( &config.permissions.shell_environment_policy, /*thread_id*/ None, ); @@ -258,13 +258,11 @@ async fn run_command_under_sandbox( let network = network_proxy .as_ref() .map(codex_core::config::StartedNetworkProxy::proxy); - let managed_mitm_ca_trust_bundle_path = match network.as_ref() { - Some(network) => network.managed_mitm_ca_trust_bundle_path(), - None => None, - }; - let runtime_permission_profile = with_managed_mitm_ca_readable_root( + let runtime_permission_profile = prepare_managed_network_child( + network.as_ref(), + &mut env, + cwd.as_path(), config.permissions.effective_permission_profile(), - managed_mitm_ca_trust_bundle_path.as_ref(), sandbox_policy_cwd.as_path(), ); @@ -291,9 +289,6 @@ async fn run_command_under_sandbox( env, |env_map| { env_map.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); - if let Some(network) = network.as_ref() { - network.apply_to_env(env_map); - } }, ) .await? @@ -321,11 +316,7 @@ async fn run_command_under_sandbox( cwd.to_path_buf(), network_sandbox_policy, env, - |env_map| { - if let Some(network) = network.as_ref() { - network.apply_to_env(env_map); - } - }, + |_env_map| {}, ) .await? } diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index f8c559e506..9ed2c1be6e 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -12,10 +12,14 @@ use codex_core_plugins::PluginsManager; use codex_core_plugins::installed_marketplaces::marketplace_install_root; use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root; use codex_core_plugins::marketplace::MarketplaceListError; +use codex_core_plugins::marketplace::MarketplacePluginAuthPolicy; +use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy; +use codex_core_plugins::marketplace::MarketplacePluginSource; use codex_core_plugins::marketplace::find_marketplace_manifest_path; use codex_plugin::PluginId; use codex_plugin::validate_plugin_segment; use codex_utils_cli::CliConfigOverrides; +use serde::Serialize; use std::path::Path; use std::path::PathBuf; @@ -73,12 +77,20 @@ pub struct AddPluginArgs { #[derive(Debug, Parser)] #[command( bin_name = "codex plugin list", - after_help = "Examples:\n codex plugin list\n codex plugin list --marketplace debug" + after_help = "Examples:\n codex plugin list\n codex plugin list --marketplace debug\n codex plugin list --json\n codex plugin list --available --json" )] pub struct ListPluginsArgs { /// Only list plugins from this configured marketplace name. #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] marketplace_name: Option, + + /// Output plugin list as JSON. + #[arg(long = "json")] + json: bool, + + /// Include uninstalled marketplace plugins in the JSON output. + #[arg(long = "available", requires = "json")] + available: bool, } #[derive(Debug, Parser)] @@ -166,6 +178,12 @@ pub async fn run_plugin_list( }) .collect::>(); + if args.json { + let output = JsonPluginListOutput::from_marketplaces(marketplaces, args.available); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + if marketplaces.is_empty() { if let Some(marketplace_name) = args.marketplace_name { println!("No plugins found in marketplace `{marketplace_name}`."); @@ -240,6 +258,138 @@ pub async fn run_plugin_list( Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonPluginListOutput { + installed: Vec, + available: Vec, +} + +impl JsonPluginListOutput { + fn from_marketplaces( + marketplaces: Vec, + include_available: bool, + ) -> Self { + let mut installed = Vec::new(); + let mut available = Vec::new(); + + for marketplace in marketplaces { + for plugin in marketplace.plugins { + let entry = JsonPluginListEntry::from_configured_plugin(&marketplace.name, plugin); + if entry.installed { + installed.push(entry); + } else if include_available { + available.push(entry); + } + } + } + + Self { + installed, + available, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonPluginListEntry { + plugin_id: String, + name: String, + marketplace_name: String, + version: Option, + installed: bool, + enabled: bool, + source: JsonPluginSource, + install_policy: &'static str, + auth_policy: &'static str, +} + +impl JsonPluginListEntry { + fn from_configured_plugin( + marketplace_name: &str, + plugin: codex_core_plugins::ConfiguredMarketplacePlugin, + ) -> Self { + let version = plugin.installed_version.or(plugin.local_version); + Self { + plugin_id: plugin.id, + name: plugin.name, + marketplace_name: marketplace_name.to_string(), + version, + installed: plugin.installed, + enabled: plugin.enabled, + source: JsonPluginSource::from_marketplace_source(plugin.source), + install_policy: install_policy_label(plugin.policy.installation), + auth_policy: auth_policy_label(plugin.policy.authentication), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(tag = "source", rename_all = "kebab-case")] +enum JsonPluginSource { + Local { + path: String, + }, + Git { + url: String, + #[serde(rename = "ref", skip_serializing_if = "Option::is_none")] + ref_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + sha: Option, + }, + GitSubdir { + url: String, + path: String, + #[serde(rename = "ref", skip_serializing_if = "Option::is_none")] + ref_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + sha: Option, + }, +} + +impl JsonPluginSource { + fn from_marketplace_source(source: MarketplacePluginSource) -> Self { + match source { + MarketplacePluginSource::Local { path } => Self::Local { + path: path.as_path().display().to_string(), + }, + MarketplacePluginSource::Git { + url, + path: Some(path), + ref_name, + sha, + } => Self::GitSubdir { + url, + path, + ref_name, + sha, + }, + MarketplacePluginSource::Git { + url, + path: None, + ref_name, + sha, + } => Self::Git { url, ref_name, sha }, + } + } +} + +fn install_policy_label(policy: MarketplacePluginInstallPolicy) -> &'static str { + match policy { + MarketplacePluginInstallPolicy::NotAvailable => "NOT_AVAILABLE", + MarketplacePluginInstallPolicy::Available => "AVAILABLE", + MarketplacePluginInstallPolicy::InstalledByDefault => "INSTALLED_BY_DEFAULT", + } +} + +fn auth_policy_label(policy: MarketplacePluginAuthPolicy) -> &'static str { + match policy { + MarketplacePluginAuthPolicy::OnInstall => "ON_INSTALL", + MarketplacePluginAuthPolicy::OnUse => "ON_USE", + } +} + pub async fn run_plugin_remove( overrides: Vec<(String, toml::Value)>, args: RemovePluginArgs, diff --git a/codex-rs/cli/tests/plugin_cli.rs b/codex-rs/cli/tests/plugin_cli.rs index 5719b8f103..4ce3617211 100644 --- a/codex-rs/cli/tests/plugin_cli.rs +++ b/codex-rs/cli/tests/plugin_cli.rs @@ -4,6 +4,8 @@ use codex_config::MarketplaceConfigUpdate; use codex_config::record_user_marketplace; use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; +use pretty_assertions::assert_eq; +use serde_json::json; use std::path::Path; use tempfile::TempDir; @@ -489,6 +491,103 @@ async fn plugin_list_prints_plugins_in_a_table() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<()> { + let (codex_home, source) = setup_local_marketplace()?; + let plugin_path = source.path().join("plugins").join("sample"); + + let assert = codex_command(codex_home.path())? + .args(["plugin", "list", "--available", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "installed": [], + "available": [ + { + "pluginId": "sample@debug", + "name": "sample", + "marketplaceName": "debug", + "version": "1.2.3", + "installed": false, + "enabled": false, + "source": { + "source": "local", + "path": plugin_path.display().to_string(), + }, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_INSTALL", + }, + ], + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_json_prints_installed_plugins() -> Result<()> { + let (codex_home, source) = setup_local_marketplace()?; + let plugin_path = source.path().join("plugins").join("sample"); + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .success(); + + let assert = codex_command(codex_home.path())? + .args(["plugin", "list", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "installed": [ + { + "pluginId": "sample@debug", + "name": "sample", + "marketplaceName": "debug", + "version": "1.2.3", + "installed": true, + "enabled": true, + "source": { + "source": "local", + "path": plugin_path.display().to_string(), + }, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_INSTALL", + }, + ], + "available": [], + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_available_requires_json() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "list", "--available"]) + .assert() + .failure() + .stderr(contains( + "the following required arguments were not provided", + )) + .stderr(contains("--json")); + + Ok(()) +} + #[tokio::test] async fn plugin_list_shows_installed_version_when_plugin_is_installed() -> Result<()> { let (codex_home, _source) = setup_local_marketplace()?; diff --git a/codex-rs/cloud-config/Cargo.toml b/codex-rs/cloud-config/Cargo.toml index aeba70a489..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 } @@ -17,14 +16,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/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 0beb91780c..0e742f5522 100644 --- a/codex-rs/cloud-config/src/lib.rs +++ b/codex-rs/cloud-config/src/lib.rs @@ -1,2482 +1,14 @@ -//! 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. - -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_config::CloudRequirementsLoadError; -use codex_config::CloudRequirementsLoadErrorCode; -use codex_config::CloudRequirementsLoader; -use codex_config::ConfigRequirementsToml; -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; -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_REQUIREMENTS_CACHE_FILENAME: &str = "cloud-requirements-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_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." -); -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"; -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 requirements cache read because auth identity is incomplete.")] - AuthIdentityIncomplete, - #[error("Cloud requirements cache file not found.")] - CacheFileNotFound, - #[error("Failed to read cloud requirements cache: {0}.")] - CacheReadFailed(String), - #[error("Failed to parse cloud requirements cache: {0}.")] - CacheParseFailed(String), - #[error("Cloud requirements cache failed signature verification.")] - CacheSignatureInvalid, - #[error("Ignoring cloud requirements cache because cached identity is incomplete.")] - CacheIdentityIncomplete, - #[error("Ignoring cloud requirements cache for different auth identity.")] - CacheIdentityMismatch, - #[error("Cloud requirements cache expired.")] - CacheExpired, -} - -#[derive(Debug, Error)] -enum CloudRequirementsError { - #[error("failed to write cloud requirements cache")] - CacheWrite, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct CloudRequirementsCacheFile { - signed_payload: CloudRequirementsCacheSignedPayload, - signature: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct CloudRequirementsCacheSignedPayload { - cached_at: DateTime, - expires_at: DateTime, - chatgpt_user_id: Option, - account_id: Option, - contents: Option, -} - -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); - 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 cache_payload_bytes(payload: &CloudRequirementsCacheSignedPayload) -> Option> { - serde_json::to_vec(&payload).ok() -} - -#[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>; -} - -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, FetchAttemptError> { - 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" - ); - }) - .map_err(|_| FetchAttemptError::Retryable(RetryableFailureKind::BackendClientInit))?; - - let response = client - .get_config_requirements_file() - .await - .inspect_err(|err| tracing::warn!(error = %err, "Failed to fetch cloud requirements")) - .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 }) - } - })?; - - let Some(contents) = response.contents else { - tracing::info!( - "Cloud requirements response missing contents; treating as no requirements" - ); - return Ok(None); - }; - - Ok(Some(contents)) - } -} - -#[derive(Clone)] -struct CloudRequirementsService { - auth_manager: Arc, - fetcher: Arc, - requirements_base_dir: PathBuf, - cache_path: PathBuf, - timeout: Duration, -} - -impl CloudRequirementsService { - fn new( - auth_manager: Arc, - fetcher: Arc, - codex_home: PathBuf, - timeout: Duration, - ) -> Self { - Self { - auth_manager, - fetcher, - requirements_base_dir: codex_home.clone(), - cache_path: codex_home.join(CLOUD_REQUIREMENTS_CACHE_FILENAME), - timeout, - } - } - - async fn fetch_with_timeout( - &self, - ) -> Result, CloudRequirementsLoadError> { - let _timer = - codex_otel::start_global_timer("codex.cloud_requirements.fetch.duration_ms", &[]); - let started_at = Instant::now(); - let fetch_result = timeout(self.timeout, self.fetch()) - .await - .inspect_err(|_| { - let message = format!( - "Timed out waiting for cloud requirements after {}s", - self.timeout.as_secs() - ); - tracing::error!("{message}"); - emit_load_metric("startup", "error"); - }) - .map_err(|_| { - CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Timeout, - /*status_code*/ None, - format!( - "timed out waiting for cloud requirements after {}s", - self.timeout.as_secs() - ), - ) - })?; - - let result = match fetch_result { - Ok(result) => result, - Err(err) => { - emit_load_metric("startup", "error"); - return Err(err); - } - }; - - match result.as_ref() { - Some(requirements) => { - tracing::info!( - elapsed_ms = started_at.elapsed().as_millis(), - requirements = ?requirements, - "Cloud requirements load completed" - ); - emit_load_metric("startup", "success"); - } - None => { - tracing::info!( - elapsed_ms = started_at.elapsed().as_millis(), - "Cloud requirements load completed (none)" - ); - emit_load_metric("startup", "success"); - } - } - - Ok(result) - } - - async fn fetch(&self) -> Result, CloudRequirementsLoadError> { - 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) => { - tracing::info!( - path = %self.cache_path.display(), - "Using cached cloud requirements" - ); - return Ok(signed_payload.requirements(&self.requirements_base_dir)); - } - 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, CloudRequirementsLoadError> { - 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) => { - emit_fetch_attempt_metric( - trigger, attempt, "success", /*status_code*/ None, - ); - contents - } - 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 requirements; 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 requirements 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" - ); - emit_fetch_final_metric( - trigger, - "error", - "auth_recovery_missing_auth", - attempt, - status_code, - ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::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 requirements request" - ); - emit_fetch_final_metric( - trigger, - "error", - "auth_recovery_unrecoverable", - attempt, - status_code, - ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::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 requirements request; retrying" - ); - sleep(backoff(attempt as u64)).await; - } - attempt += 1; - continue; - } - } - } - - tracing::warn!( - error = %message, - "Cloud requirements request was unauthorized and no auth recovery is available" - ); - emit_fetch_final_metric( - trigger, - "error", - "auth_recovery_unavailable", - attempt, - status_code, - ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::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, - }; - - 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"); - } - - emit_fetch_final_metric( - trigger, "success", "none", attempt, /*status_code*/ None, - ); - return Ok(requirements); - } - - emit_fetch_final_metric( - trigger, - "error", - "request_retry_exhausted", - CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - last_status_code, - ); - tracing::error!( - path = %self.cache_path.display(), - "{CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE}" - ); - Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::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 requirements cache from remote; keeping existing cache" - ); - emit_load_metric("refresh", "error"); - } - } - } - } - - 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(_) => emit_load_metric("refresh", "success"), - Err(err) => { - tracing::error!( - path = %self.cache_path.display(), - error = %err, - "Failed to refresh cloud requirements cache from remote" - ); - emit_load_metric("refresh", "error"); - } - } - 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); - } - - 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, - contents: Option, - ) -> 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 { - cached_at: now, - expires_at, - chatgpt_user_id, - account_id, - contents, - }; - 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_requirements_loader( - auth_manager: Arc, - chatgpt_base_url: String, - codex_home: PathBuf, -) -> CloudRequirementsLoader { - 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 requirements 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 { - task.await.map_err(|err| { - tracing::error!(error = %err, "Cloud requirements task failed"); - CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Internal, - /*status_code*/ None, - format!("cloud requirements load failed: {err}"), - ) - })? - }) -} - -pub async fn cloud_requirements_loader_for_storage( - codex_home: PathBuf, - enable_codex_api_key_env: bool, - credentials_store_mode: AuthCredentialsStoreMode, - chatgpt_base_url: String, -) -> CloudRequirementsLoader { - let auth_manager = AuthManager::shared( - codex_home.clone(), - enable_codex_api_key_env, - credentials_store_mode, - Some(chatgpt_base_url.clone()), - ) - .await; - cloud_requirements_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); - } - - let _guard = AbsolutePathBufGuard::new(requirements_base_dir); - let requirements: ConfigRequirementsToml = toml::from_str(contents)?; - if requirements.is_empty() { - Ok(None) - } else { - Ok(Some(requirements)) - } -} - -fn format_cloud_requirements_parse_failed_message( - _contents: &str, - err: &toml::de::Error, -) -> String { - format!("{CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE}\n\nDetails:\n{err}") -} - -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, -) { - 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), - ], - ); -} - -fn emit_load_metric(trigger: &str, outcome: &str) { - emit_metric( - CLOUD_REQUIREMENTS_LOAD_METRIC, - vec![ - ("trigger", trigger.to_string()), - ("outcome", outcome.to_string()), - ], - ); -} - -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_config::AppToolApproval; - 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(()) - } - - 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, - ) - } - - 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 - } - - 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, - } - } - - 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 parse_for_fetch(contents: Option<&str>) -> Option { - contents.and_then(|contents| { - parse_cloud_requirements(contents, &std::env::temp_dir()) - .ok() - .flatten() - }) - } - - fn request_error() -> FetchAttemptError { - FetchAttemptError::Retryable(RetryableFailureKind::Request { status_code: None }) - } - - struct StaticFetcher { - contents: Option, - } - - #[async_trait::async_trait] - impl RequirementsFetcher for StaticFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { - Ok(self.contents.clone()) - } - } - - struct PendingFetcher; - - #[async_trait::async_trait] - impl RequirementsFetcher for PendingFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { - pending::<()>().await; - Ok(None) - } - } - - struct SequenceFetcher { - responses: tokio::sync::Mutex, FetchAttemptError>>>, - request_count: AtomicUsize, - } - - impl SequenceFetcher { - fn new(responses: Vec, FetchAttemptError>>) -> Self { - Self { - responses: tokio::sync::Mutex::new(VecDeque::from(responses)), - request_count: AtomicUsize::new(0), - } - } - } - - #[async_trait::async_trait] - impl RequirementsFetcher for SequenceFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { - self.request_count.fetch_add(1, Ordering::SeqCst); - let mut responses = self.responses.lock().await; - responses.pop_front().unwrap_or(Ok(None)) - } - } - - struct TokenFetcher { - expected_token: String, - contents: String, - request_count: AtomicUsize, - } - - #[async_trait::async_trait] - impl RequirementsFetcher for TokenFetcher { - async fn fetch_requirements( - &self, - auth: &CodexAuth, - ) -> Result, FetchAttemptError> { - 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())) - } else { - Err(FetchAttemptError::Unauthorized { - status_code: Some(401), - message: "GET /config/requirements failed: 401".to_string(), - }) - } - } - } - - struct UnauthorizedFetcher { - message: String, - request_count: AtomicUsize, - } - - #[async_trait::async_trait] - impl RequirementsFetcher for UnauthorizedFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { - self.request_count.fetch_add(1, Ordering::SeqCst); - Err(FetchAttemptError::Unauthorized { - status_code: Some(401), - message: self.message.clone(), - }) - } - } - - #[tokio::test] - async fn fetch_cloud_requirements_skips_non_chatgpt_auth() { - let auth_manager = auth_manager_with_api_key().await; - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager, - Arc::new(StaticFetcher { contents: None }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let result = service.fetch().await; - assert_eq!(result, Ok(None)); - } - - #[tokio::test] - async fn fetch_cloud_requirements_skips_non_business_or_enterprise_plan() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("pro").await, - Arc::new(StaticFetcher { contents: None }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let result = service.fetch().await; - assert_eq!(result, Ok(None)); - } - - #[tokio::test] - async fn fetch_cloud_requirements_skips_team_like_usage_based_plan() { - 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()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - assert_eq!(service.fetch().await, Ok(None)); - } - - #[tokio::test] - async fn fetch_cloud_requirements_allows_business_plan() { - 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, - ); - 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, - })) - ); - } - - #[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"); - - assert!(cloud_requirements_eligible_auth(&auth)); - } - - #[tokio::test] - async fn fetch_cloud_requirements_allows_business_like_usage_based_plan() { - 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()), - }), - 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, - })) - ); - } - - #[tokio::test] - async fn fetch_cloud_requirements_allows_hc_plan_as_enterprise() { - 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()), - }), - 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, - })) - ); - } - - #[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() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - Arc::new(StaticFetcher { - contents: Some( - r#" -[permissions.filesystem] -deny_read = ["./sensitive/**/*.txt"] -"# - .to_string(), - ), - }), - 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() - }) - ); - } - - #[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() - }) - ); - } - - #[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() - }) - ); - } - - #[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, - 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 requirements task"); - let err = result.expect_err("cloud requirements timeout should fail closed"); - assert!( - err.to_string() - .contains("timed out waiting for cloud requirements") - ); - } - - #[tokio::test(start_paused = true)] - 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())), - ])); - 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("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!(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 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(), - request_count: AtomicUsize::new(0), - }); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - Arc::clone(&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!(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 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(), - request_count: AtomicUsize::new(0), - }); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - Arc::clone(&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, - })) - ); - - 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()) - ); - 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; - 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/requirements failed: 401".to_string(), - request_count: AtomicUsize::new(0), - }); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - Arc::clone(&auth.manager), - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let err = service - .fetch() - .await - .expect_err("cloud requirements 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." - ); - 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/requirements 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 requirements 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"), - ) - .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()) - ); - } - - #[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 { - 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", - /*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(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!(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 requirements task") - .expect_err("cloud requirements retry exhaustion should fail closed"); - assert_eq!(err.to_string(), CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE); - assert_eq!(err.code(), CloudRequirementsLoadErrorCode::RequestFailed); - assert_eq!( - fetcher.request_count.load(Ordering::SeqCst), - CLOUD_REQUIREMENTS_MAX_ATTEMPTS - ); - } - - #[tokio::test] - async fn refresh_from_remote_updates_cached_cloud_requirements() { - 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(), - )), - ])); - 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(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!(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"); - 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, - }) - ); - } -} +//! This crate owns transport, caching, and refresh behavior for cloud-delivered +//! config data. Parsing and composition remain in `codex-config`. + +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..eed94c0a10 --- /dev/null +++ b/codex-rs/cloud-config/src/service.rs @@ -0,0 +1,501 @@ +//! 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 | PlanType::Edu)) +} + +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..abedca4b07 --- /dev/null +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -0,0 +1,1020 @@ +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_individual_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_allows_eligible_workspace_plans_and_writes_cache() { + for plan_type in [ + "business", + "enterprise_cbp_usage_based", + "enterprise", + "hc", + "edu", + "education", + ] { + let bundle = test_bundle(); + let fetcher = Arc::new(StaticBundleClient::new(bundle.clone())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan(plan_type).await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!( + service.load_startup_bundle().await, + Ok(Some(bundle)), + "plan_type: {plan_type}" + ); + assert_eq!( + fetcher.request_count.load(Ordering::SeqCst), + 1, + "plan_type: {plan_type}" + ); + assert!( + codex_home + .path() + .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME) + .exists(), + "plan_type: {plan_type}" + ); + } +} + +#[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_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_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(()) +} diff --git a/codex-rs/codex-api/src/rate_limits.rs b/codex-rs/codex-api/src/rate_limits.rs index a2ad876671..91f96fc3b8 100644 --- a/codex-rs/codex-api/src/rate_limits.rs +++ b/codex-rs/codex-api/src/rate_limits.rs @@ -93,6 +93,7 @@ pub fn parse_rate_limit_for_limit( primary, secondary, credits, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }) @@ -157,6 +158,7 @@ pub fn parse_rate_limit_event(payload: &str) -> Option { primary, secondary, credits, + individual_limit: None, plan_type: event.plan_type, rate_limit_reached_type: None, }) diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 742b39d5fd..9ff273211f 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -100,6 +100,7 @@ async fn models_client_hits_models_endpoint() { supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, }], }; diff --git a/codex-rs/codex-backend-openapi-models/src/models/mod.rs b/codex-rs/codex-backend-openapi-models/src/models/mod.rs index f0504a9c82..c3f4e6beec 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/mod.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/mod.rs @@ -56,3 +56,9 @@ pub use self::rate_limit_window_snapshot::RateLimitWindowSnapshot; pub(crate) mod credit_status_details; pub use self::credit_status_details::CreditStatusDetails; + +pub(crate) mod spend_control_limit_details; +pub use self::spend_control_limit_details::SpendControlLimitDetails; + +pub(crate) mod spend_control_status_details; +pub use self::spend_control_status_details::SpendControlStatusDetails; diff --git a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs index 56d4a8ee10..066a81b524 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs @@ -30,6 +30,13 @@ pub struct RateLimitStatusPayload { skip_serializing_if = "Option::is_none" )] pub credits: Option>>, + #[serde( + rename = "spend_control", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub spend_control: Option>>, #[serde( rename = "additional_rate_limits", default, @@ -52,6 +59,7 @@ impl RateLimitStatusPayload { plan_type, rate_limit: None, credits: None, + spend_control: None, additional_rate_limits: None, rate_limit_reached_type: None, } diff --git a/codex-rs/codex-backend-openapi-models/src/models/spend_control_limit_details.rs b/codex-rs/codex-backend-openapi-models/src/models/spend_control_limit_details.rs new file mode 100644 index 0000000000..6fc1fc51a9 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/spend_control_limit_details.rs @@ -0,0 +1,60 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct SpendControlLimitDetails { + #[serde( + rename = "source", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub source: Option>, + #[serde(rename = "limit")] + pub limit: String, + #[serde(rename = "used")] + pub used: String, + #[serde(rename = "remaining")] + pub remaining: String, + #[serde(rename = "used_percent")] + pub used_percent: i32, + #[serde(rename = "remaining_percent")] + pub remaining_percent: i32, + #[serde(rename = "reset_after_seconds")] + pub reset_after_seconds: i32, + #[serde(rename = "reset_at")] + pub reset_at: i32, +} + +impl SpendControlLimitDetails { + pub fn new( + limit: String, + used: String, + remaining: String, + used_percent: i32, + remaining_percent: i32, + reset_after_seconds: i32, + reset_at: i32, + ) -> SpendControlLimitDetails { + SpendControlLimitDetails { + source: None, + limit, + used, + remaining, + used_percent, + remaining_percent, + reset_after_seconds, + reset_at, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/spend_control_status_details.rs b/codex-rs/codex-backend-openapi-models/src/models/spend_control_status_details.rs new file mode 100644 index 0000000000..1283acabf8 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/spend_control_status_details.rs @@ -0,0 +1,35 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct SpendControlStatusDetails { + #[serde(rename = "reached")] + pub reached: bool, + #[serde( + rename = "individual_limit", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub individual_limit: Option>>, +} + +impl SpendControlStatusDetails { + pub fn new(reached: bool) -> SpendControlStatusDetails { + SpendControlStatusDetails { + reached, + individual_limit: None, + } + } +} diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 51a6f18686..9633655846 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -21,7 +21,6 @@ use codex_config::Constrained; use codex_config::McpServerConfig; use codex_config::McpServerTransportConfig; use codex_config::types::AppToolApproval; -use codex_config::types::ApprovalsReviewer; use codex_config::types::OAuthCredentialsStoreMode; use codex_login::CodexAuth; use codex_plugin::PluginCapabilitySummary; @@ -91,7 +90,6 @@ pub fn mcp_permission_prompt_is_auto_approved( #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct McpPermissionPromptAutoApproveContext { - pub approvals_reviewer: Option, pub tool_approval_mode: Option, } diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index d4d888f202..b29f7a9e58 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -1,7 +1,6 @@ use super::*; use codex_config::Constrained; use codex_config::types::AppToolApproval; -use codex_config::types::ApprovalsReviewer; use codex_login::CodexAuth; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; @@ -96,7 +95,6 @@ fn mcp_prompt_auto_approval_honors_approved_tools_in_all_permission_modes() { approval_policy, &PermissionProfile::read_only(), McpPermissionPromptAutoApproveContext { - approvals_reviewer: Some(ApprovalsReviewer::User), tool_approval_mode: Some(AppToolApproval::Approve), }, )); @@ -106,7 +104,6 @@ fn mcp_prompt_auto_approval_honors_approved_tools_in_all_permission_modes() { AskForApproval::OnRequest, &PermissionProfile::read_only(), McpPermissionPromptAutoApproveContext { - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), tool_approval_mode: Some(AppToolApproval::Auto), }, )); @@ -118,7 +115,6 @@ fn mcp_prompt_auto_approval_rejects_auto_mode_in_default_permission_mode() { AskForApproval::OnRequest, &PermissionProfile::read_only(), McpPermissionPromptAutoApproveContext { - approvals_reviewer: Some(ApprovalsReviewer::User), tool_approval_mode: Some(AppToolApproval::Auto), }, )); 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/config_toml.rs b/codex-rs/config/src/config_toml.rs index 0765819fb7..7c060cadb7 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -724,36 +724,27 @@ impl ConfigToml { pub async fn derive_permission_profile( &self, sandbox_mode_override: Option, - profile_sandbox_mode: Option, windows_sandbox_level: WindowsSandboxLevel, active_project: Option<&ProjectConfig>, permission_profile_constraint: Option<&crate::Constrained>, ) -> PermissionProfile { - let sandbox_mode_was_explicit = sandbox_mode_override.is_some() - || profile_sandbox_mode.is_some() - || self.sandbox_mode.is_some(); - let resolved_sandbox_mode = sandbox_mode_override - .or(profile_sandbox_mode) - .or(self.sandbox_mode) - .or(if sandbox_mode_was_explicit { - None - } else { + let configured_sandbox_mode = sandbox_mode_override.or(self.sandbox_mode); + let resolved_sandbox_mode = configured_sandbox_mode + .or_else(|| { // If no sandbox_mode is set but this directory has a trust decision, // default to workspace-write except on unsandboxed Windows where we // default to read-only. - active_project.and_then(|p| { - if p.is_trusted() || p.is_untrusted() { + active_project + .filter(|project| project.is_trusted() || project.is_untrusted()) + .map(|_| { if cfg!(target_os = "windows") && windows_sandbox_level == WindowsSandboxLevel::Disabled { - Some(SandboxMode::ReadOnly) + SandboxMode::ReadOnly } else { - Some(SandboxMode::WorkspaceWrite) + SandboxMode::WorkspaceWrite } - } else { - None - } - }) + }) }) .unwrap_or_default(); let effective_sandbox_mode = if cfg!(target_os = "windows") @@ -791,7 +782,7 @@ impl ConfigToml { }, SandboxMode::DangerFullAccess => PermissionProfile::Disabled, }; - if !sandbox_mode_was_explicit + if configured_sandbox_mode.is_none() && let Some(constraint) = permission_profile_constraint && let Err(err) = constraint.can_set(&permission_profile) { 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/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/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/config/src/types.rs b/codex-rs/config/src/types.rs index 598b7fdee7..7b634a5188 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -425,6 +425,10 @@ pub struct AppConfig { #[serde(default = "default_enabled")] pub enabled: bool, + /// Reviewer for approval prompts from this app, overriding the thread default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approvals_reviewer: Option, + /// Whether tools with `destructive_hint = true` are allowed for this app. #[serde(default, skip_serializing_if = "Option::is_none")] pub destructive_enabled: Option, diff --git a/codex-rs/core-plugins/src/discoverable.rs b/codex-rs/core-plugins/src/discoverable.rs new file mode 100644 index 0000000000..ccf093d816 --- /dev/null +++ b/codex-rs/core-plugins/src/discoverable.rs @@ -0,0 +1,218 @@ +use anyhow::Context; +use codex_app_server_protocol::PluginAvailability; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_login::CodexAuth; +use codex_plugin::PluginCapabilitySummary; +use std::collections::HashSet; +use tracing::warn; + +use crate::OPENAI_BUNDLED_MARKETPLACE_NAME; +use crate::OPENAI_CURATED_MARKETPLACE_NAME; +use crate::PluginsConfigInput; +use crate::PluginsManager; +use crate::marketplace::MarketplacePluginInstallPolicy; +use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; +use crate::remote::RemotePluginScope; + +const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ + "github@openai-curated", + "notion@openai-curated", + "slack@openai-curated", + "gmail@openai-curated", + "google-calendar@openai-curated", + "google-drive@openai-curated", + "openai-developers@openai-curated", + "canva@openai-curated", + "teams@openai-curated", + "sharepoint@openai-curated", + "outlook-email@openai-curated", + "outlook-calendar@openai-curated", + "linear@openai-curated", + "figma@openai-curated", + "github@openai-curated-remote", + "notion@openai-curated-remote", + "slack@openai-curated-remote", + "gmail@openai-curated-remote", + "google-calendar@openai-curated-remote", + "google-drive@openai-curated-remote", + "openai-developers@openai-curated-remote", + "canva@openai-curated-remote", + "teams@openai-curated-remote", + "sharepoint@openai-curated-remote", + "outlook-email@openai-curated-remote", + "outlook-calendar@openai-curated-remote", + "linear@openai-curated-remote", + "figma@openai-curated-remote", + "chrome@openai-bundled", + "computer-use@openai-bundled", +]; + +const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[ + OPENAI_BUNDLED_MARKETPLACE_NAME, + OPENAI_CURATED_MARKETPLACE_NAME, + REMOTE_GLOBAL_MARKETPLACE_NAME, +]; + +#[derive(Debug, Clone)] +pub struct ToolSuggestPluginDiscoveryInput { + pub plugins: PluginsConfigInput, + pub configured_plugin_ids: HashSet, + pub disabled_plugin_ids: HashSet, + pub loaded_plugin_app_connector_ids: HashSet, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ToolSuggestDiscoverablePlugin { + pub id: String, + pub name: String, + pub description: Option, + pub has_skills: bool, + pub mcp_server_names: Vec, + pub app_connector_ids: Vec, +} + +impl PluginsManager { + pub async fn list_tool_suggest_discoverable_plugins( + &self, + input: &ToolSuggestPluginDiscoveryInput, + auth: Option<&CodexAuth>, + ) -> anyhow::Result> { + if !input.plugins.plugins_enabled { + return Ok(Vec::new()); + } + + let marketplaces = self + .list_marketplaces_for_config(&input.plugins, &[]) + .context("failed to list plugin marketplaces for tool suggestions")? + .marketplaces; + let mut installed_app_connector_ids = self + .plugins_for_config(&input.plugins) + .await + .capability_summaries() + .iter() + .flat_map(|plugin| plugin.app_connector_ids.iter()) + .map(|connector_id| connector_id.0.clone()) + .collect::>(); + installed_app_connector_ids.extend(input.loaded_plugin_app_connector_ids.iter().cloned()); + let remote_installed_marketplaces = if input.plugins.remote_plugin_enabled { + self.build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Global]) + } else { + None + }; + + let mut discoverable_plugins = Vec::::new(); + for marketplace in marketplaces { + let marketplace_name = marketplace.name; + if input.plugins.remote_plugin_enabled + && marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME + { + continue; + } + let is_allowlisted_marketplace = TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST + .contains(&marketplace_name.as_str()); + + for plugin in marketplace.plugins { + let is_configured_plugin = input.configured_plugin_ids.contains(plugin.id.as_str()); + let is_fallback_plugin = + TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str()); + if plugin.installed + || plugin.policy.installation == MarketplacePluginInstallPolicy::NotAvailable + || input.disabled_plugin_ids.contains(plugin.id.as_str()) + || (!is_allowlisted_marketplace && !is_configured_plugin) + { + continue; + } + + let plugin_id = plugin.id.clone(); + + match self + .read_plugin_detail_for_marketplace_plugin( + &input.plugins, + &marketplace_name, + plugin, + ) + .await + { + Ok(plugin) => { + let plugin: PluginCapabilitySummary = plugin.into(); + let matches_installed_app = + plugin.app_connector_ids.iter().any(|connector_id| { + installed_app_connector_ids.contains(connector_id.0.as_str()) + }); + if !is_configured_plugin && !is_fallback_plugin && !matches_installed_app { + continue; + } + + discoverable_plugins.push(ToolSuggestDiscoverablePlugin { + id: plugin.config_name, + name: plugin.display_name, + description: plugin.description, + has_skills: plugin.has_skills, + mcp_server_names: plugin.mcp_server_names, + app_connector_ids: plugin + .app_connector_ids + .into_iter() + .map(|connector_id| connector_id.0) + .collect(), + }); + } + Err(err) => { + warn!("failed to load discoverable plugin suggestion {plugin_id}: {err:#}") + } + } + } + } + if let Some(remote_installed_marketplaces) = remote_installed_marketplaces.as_ref() { + let installed_remote_plugin_ids = remote_installed_marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .map(|plugin| plugin.remote_plugin_id.clone()) + .collect::>(); + for plugin in + self.cached_global_remote_discoverable_plugins_for_config(&input.plugins, auth) + { + let is_configured_plugin = input + .configured_plugin_ids + .contains(plugin.config_id.as_str()) + || input + .configured_plugin_ids + .contains(plugin.remote_plugin_id.as_str()); + let is_fallback_plugin = + TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.config_id.as_str()); + let matches_installed_app = plugin + .app_ids + .iter() + .any(|app_id| installed_app_connector_ids.contains(app_id.as_str())); + let is_disabled = input + .disabled_plugin_ids + .contains(plugin.config_id.as_str()) + || input + .disabled_plugin_ids + .contains(plugin.remote_plugin_id.as_str()); + if installed_remote_plugin_ids.contains(&plugin.remote_plugin_id) + || plugin.install_policy == PluginInstallPolicy::NotAvailable + || plugin.availability == PluginAvailability::DisabledByAdmin + || is_disabled + || (!is_configured_plugin && !is_fallback_plugin && !matches_installed_app) + { + continue; + } + + discoverable_plugins.push(ToolSuggestDiscoverablePlugin { + id: plugin.config_id, + name: plugin.name, + description: plugin.description, + has_skills: plugin.has_skills, + mcp_server_names: Vec::new(), + app_connector_ids: plugin.app_ids, + }); + } + } + discoverable_plugins.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(discoverable_plugins) + } +} diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 4e59162e34..4728a8c6f8 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -1,3 +1,4 @@ +mod discoverable; pub mod installed_marketplaces; pub mod loader; mod manager; @@ -20,28 +21,11 @@ pub mod toggles; pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; pub const OPENAI_BUNDLED_MARKETPLACE_NAME: &str = "openai-bundled"; -pub const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ - "github@openai-curated", - "notion@openai-curated", - "slack@openai-curated", - "gmail@openai-curated", - "google-calendar@openai-curated", - "google-drive@openai-curated", - "openai-developers@openai-curated", - "canva@openai-curated", - "teams@openai-curated", - "sharepoint@openai-curated", - "outlook-email@openai-curated", - "outlook-calendar@openai-curated", - "linear@openai-curated", - "figma@openai-curated", - "chrome@openai-bundled", - "computer-use@openai-bundled", -]; - pub type LoadedPlugin = codex_plugin::LoadedPlugin; pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome; +pub use discoverable::ToolSuggestDiscoverablePlugin; +pub use discoverable::ToolSuggestPluginDiscoveryInput; pub use manager::ConfiguredMarketplace; pub use manager::ConfiguredMarketplaceListOutcome; pub use manager::ConfiguredMarketplacePlugin; diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index ff596273f9..8022c6c86d 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -592,6 +592,31 @@ impl PluginsManager { Some(crate::remote::group_remote_installed_plugins_by_marketplaces(plugins, visible_scopes)) } + pub fn cached_global_remote_discoverable_plugins_for_config( + &self, + config: &PluginsConfigInput, + auth: Option<&CodexAuth>, + ) -> Vec { + if !config.plugins_enabled || !config.remote_plugin_enabled { + return Vec::new(); + } + let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else { + return Vec::new(); + }; + let Some(account_id) = auth.get_account_id() else { + return Vec::new(); + }; + if account_id.is_empty() { + return Vec::new(); + } + + crate::remote::cached_global_remote_discoverable_plugins( + self.codex_home.as_path(), + &remote_plugin_service_config(config), + auth, + ) + } + pub async fn build_and_cache_remote_installed_plugin_marketplaces( &self, config: &PluginsConfigInput, @@ -1548,9 +1573,30 @@ impl PluginsManager { ); manager.maybe_start_remote_installed_plugin_bundle_sync( &config_for_remote_sync, - auth, + auth.clone(), on_effective_plugins_changed, ); + if config_for_remote_sync.remote_plugin_enabled { + match crate::remote::fetch_and_cache_global_remote_plugin_catalog( + manager.codex_home.as_path(), + &remote_plugin_service_config(&config_for_remote_sync), + auth.as_ref(), + ) + .await + { + Ok(()) => {} + Err( + RemotePluginCatalogError::AuthRequired + | RemotePluginCatalogError::UnsupportedAuthMode, + ) => {} + Err(err) => { + warn!( + error = %err, + "failed to warm remote plugin catalog cache" + ); + } + } + } }); let config = config.clone(); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 16ae512931..3c86f6b14b 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -12,15 +12,18 @@ use codex_plugin::PluginId; use codex_utils_absolute_path::AbsolutePathBuf; use reqwest::RequestBuilder; use serde::Deserialize; +use serde::Serialize; use serde_json::Value as JsonValue; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashSet; use std::fs; +use std::path::Path; use std::path::PathBuf; use std::time::Duration; use url::Url; +mod catalog_cache; mod remote_installed_plugin_sync; mod share; @@ -179,6 +182,18 @@ pub struct RemotePluginSkillDetail { pub contents: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteDiscoverablePlugin { + pub config_id: String, + pub remote_plugin_id: String, + pub name: String, + pub description: Option, + pub has_skills: bool, + pub app_ids: Vec, + pub install_policy: PluginInstallPolicy, + pub availability: PluginAvailability, +} + pub fn is_valid_remote_plugin_id(plugin_id: &str) -> bool { !plugin_id.is_empty() && plugin_id @@ -293,7 +308,7 @@ pub enum RemotePluginCatalogError { CacheRemove(String), } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)] pub enum RemotePluginScope { #[serde(rename = "GLOBAL")] Global, @@ -340,7 +355,7 @@ struct RemotePluginPagination { next_page_token: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginSkillInterfaceResponse { display_name: Option, short_description: Option, @@ -350,7 +365,7 @@ struct RemotePluginSkillInterfaceResponse { icon_large_url: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginSkillResponse { name: String, description: String, @@ -364,7 +379,7 @@ struct RemotePluginSkillDetailResponse { skill_md_contents: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginReleaseInterfaceResponse { short_description: Option, long_description: Option, @@ -383,7 +398,7 @@ struct RemotePluginReleaseInterfaceResponse { screenshot_urls: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginReleaseResponse { #[serde(default)] version: Option, @@ -402,7 +417,7 @@ struct RemotePluginReleaseResponse { skills: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginDirectoryItem { id: String, name: String, @@ -450,7 +465,7 @@ fn workspace_plugin_discoverability( }) } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginDirectorySharePrincipal { principal_type: RemotePluginSharePrincipalType, principal_id: String, @@ -489,6 +504,7 @@ pub async fn fetch_remote_marketplaces( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, sources: &[RemoteMarketplaceSource], + global_catalog_cache_path: Option<&Path>, ) -> Result, RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; let mut marketplaces = Vec::new(); @@ -512,6 +528,8 @@ pub async fn fetch_remote_marketplaces( fetch_directory_plugins_for_scope(config, auth, scope), fetch_installed_plugins_for_scope(config, auth, scope), )?; + let directory_plugins_for_cache = + global_catalog_cache_path.map(|_| directory_plugins.clone()); if let Some(marketplace) = build_remote_marketplace( scope.marketplace_name(), scope.marketplace_display_name(), @@ -521,6 +539,16 @@ pub async fn fetch_remote_marketplaces( )? { marketplaces.push(marketplace); } + if let (Some(codex_home), Some(directory_plugins)) = + (global_catalog_cache_path, directory_plugins_for_cache) + { + catalog_cache::write_cached_global_directory_plugins( + codex_home, + config, + auth, + &directory_plugins, + ); + } } RemoteMarketplaceSource::WorkspaceDirectory => { let scope = RemotePluginScope::Workspace; @@ -600,6 +628,36 @@ pub async fn fetch_remote_marketplaces( Ok(marketplaces) } +pub async fn fetch_and_cache_global_remote_plugin_catalog( + codex_home: &Path, + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, +) -> Result<(), RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + let plugins = + fetch_directory_plugins_for_scope(config, auth, RemotePluginScope::Global).await?; + catalog_cache::write_cached_global_directory_plugins(codex_home, config, auth, &plugins); + Ok(()) +} + +pub fn cached_global_remote_discoverable_plugins( + codex_home: &Path, + config: &RemotePluginServiceConfig, + auth: &CodexAuth, +) -> Vec { + catalog_cache::load_cached_global_directory_plugins(codex_home, config, auth) + .unwrap_or_default() + .into_iter() + .filter_map(|plugin| match remote_discoverable_plugin_from_directory_item(&plugin) { + Ok(plugin) => Some(plugin), + Err(err) => { + tracing::warn!(error = %err, "ignoring cached remote plugin recommendation entry"); + None + } + }) + .collect() +} + pub async fn fetch_openai_curated_remote_collection_marketplace( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, @@ -1053,6 +1111,34 @@ fn build_remote_plugin_summary( }) } +fn remote_discoverable_plugin_from_directory_item( + plugin: &RemotePluginDirectoryItem, +) -> Result { + let marketplace_name = remote_plugin_canonical_marketplace_name(plugin)?; + let plugin_id = + PluginId::new(plugin.name.clone(), marketplace_name.to_string()).map_err(|err| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "invalid remote plugin config id for `{}` in `{marketplace_name}`: {err}", + plugin.name + )) + })?; + let display_name = + non_empty_string(Some(&plugin.release.display_name)).unwrap_or_else(|| plugin.name.clone()); + let description = non_empty_string(plugin.release.interface.short_description.as_deref()) + .or_else(|| non_empty_string(Some(&plugin.release.description))); + + Ok(RemoteDiscoverablePlugin { + config_id: plugin_id.as_key(), + remote_plugin_id: plugin.id.clone(), + name: display_name, + description, + has_skills: !plugin.release.skills.is_empty(), + app_ids: plugin.release.app_ids.clone(), + install_policy: plugin.installation_policy, + availability: plugin.availability, + }) +} + fn remote_plugin_share_context( plugin: &RemotePluginDirectoryItem, ) -> Result, RemotePluginCatalogError> { diff --git a/codex-rs/core-plugins/src/remote/catalog_cache.rs b/codex-rs/core-plugins/src/remote/catalog_cache.rs new file mode 100644 index 0000000000..d49885dfbb --- /dev/null +++ b/codex-rs/core-plugins/src/remote/catalog_cache.rs @@ -0,0 +1,111 @@ +use super::RemotePluginDirectoryItem; +use super::RemotePluginServiceConfig; +use codex_login::CodexAuth; +use serde::Deserialize; +use serde::Serialize; +use std::path::Path; +use std::path::PathBuf; +use tracing::warn; + +const REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION: u8 = 1; +const REMOTE_PLUGIN_CATALOG_DISK_CACHE_DIR: &str = "cache/remote_plugin_catalog"; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct RemotePluginCatalogCacheKey { + chatgpt_base_url: String, + account_id: Option, + chatgpt_user_id: Option, + is_workspace_account: bool, +} + +impl RemotePluginCatalogCacheKey { + fn global(config: &RemotePluginServiceConfig, auth: &CodexAuth) -> Self { + Self { + chatgpt_base_url: config.chatgpt_base_url.clone(), + account_id: auth.get_account_id(), + chatgpt_user_id: auth.get_chatgpt_user_id(), + is_workspace_account: auth.is_workspace_account(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RemotePluginCatalogDiskCache { + schema_version: u8, + plugins: Vec, +} + +pub(crate) fn load_cached_global_directory_plugins( + codex_home: &Path, + config: &RemotePluginServiceConfig, + auth: &CodexAuth, +) -> Option> { + let cache_path = cache_path( + codex_home, + &RemotePluginCatalogCacheKey::global(config, auth), + ); + let bytes = match std::fs::read(&cache_path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None, + Err(err) => { + warn!( + cache_path = %cache_path.display(), + "failed to read remote plugin catalog disk cache: {err}" + ); + return None; + } + }; + let cache: RemotePluginCatalogDiskCache = match serde_json::from_slice(&bytes) { + Ok(cache) => cache, + Err(err) => { + warn!( + cache_path = %cache_path.display(), + "failed to parse remote plugin catalog disk cache: {err}" + ); + let _ = std::fs::remove_file(cache_path); + return None; + } + }; + if cache.schema_version != REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION { + let _ = std::fs::remove_file(cache_path); + return None; + } + + Some(cache.plugins) +} + +pub(crate) fn write_cached_global_directory_plugins( + codex_home: &Path, + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + plugins: &[RemotePluginDirectoryItem], +) { + let cache_path = cache_path( + codex_home, + &RemotePluginCatalogCacheKey::global(config, auth), + ); + if let Some(parent) = cache_path.parent() + && std::fs::create_dir_all(parent).is_err() + { + return; + } + let Ok(bytes) = serde_json::to_vec_pretty(&RemotePluginCatalogDiskCache { + schema_version: REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION, + plugins: plugins.to_vec(), + }) else { + return; + }; + let _ = std::fs::write(cache_path, bytes); +} + +fn cache_path(codex_home: &Path, cache_key: &RemotePluginCatalogCacheKey) -> PathBuf { + let cache_key_json = serde_json::to_vec(cache_key).unwrap_or_default(); + let mut cache_key_hash = 0xcbf29ce484222325_u64; + for byte in cache_key_json { + cache_key_hash ^= u64::from(byte); + cache_key_hash = cache_key_hash.wrapping_mul(0x100000001b3); + } + codex_home + .join(REMOTE_PLUGIN_CATALOG_DISK_CACHE_DIR) + .join(format!("{cache_key_hash:016x}.json")) +} 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-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index d8b05c604f..7dac256010 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -110,6 +110,7 @@ const SKILLS_METADATA_DIR: &str = "agents"; const SKILLS_METADATA_FILENAME: &str = "openai.yaml"; const SKILLS_DIR_NAME: &str = "skills"; const MAX_NAME_LEN: usize = 64; +const MAX_QUALIFIED_NAME_LEN: usize = 128; const MAX_DESCRIPTION_LEN: usize = 1024; const MAX_SHORT_DESCRIPTION_LEN: usize = MAX_DESCRIPTION_LEN; const MAX_DEFAULT_PROMPT_LEN: usize = MAX_DESCRIPTION_LEN; @@ -660,7 +661,8 @@ async fn parse_skill_file( policy, } = load_skill_metadata(fs, path, plugin_root).await; - validate_len(&name, MAX_NAME_LEN, "name")?; + validate_len(&base_name, MAX_NAME_LEN, "name")?; + validate_len(&name, MAX_QUALIFIED_NAME_LEN, "qualified name")?; validate_len(&description, MAX_DESCRIPTION_LEN, "description")?; if let Some(short_description) = short_description.as_deref() { validate_len( diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index 69e357d93d..f6c71b2be5 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -1301,6 +1301,84 @@ async fn namespaces_plugin_skills_using_plugin_name() { ); } +#[tokio::test] +async fn plugin_skill_name_length_limit_allows_max_qualified_name() { + let root = tempfile::tempdir().expect("tempdir"); + let plugin_name = "p".repeat(MAX_NAME_LEN - 1); + let skill_name = "s".repeat(MAX_NAME_LEN); + let plugin_root = root.path().join("plugins").join(&plugin_name); + let frontmatter = format!("name: {skill_name}\ndescription: search sample data"); + let skill_path = write_raw_skill_at(&plugin_root.join("skills"), "sample-search", &frontmatter); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + ) + .unwrap(); + + let outcome = load_skills_from_roots([SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("sample@test".to_string()), + plugin_root: Some(plugin_root.abs()), + }]) + .await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: format!("{plugin_name}:{skill_name}"), + description: "search sample data".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: Some("sample@test".to_string()), + }] + ); +} + +#[tokio::test] +async fn plugin_skill_name_length_limit_rejects_overlong_qualified_name() { + let root = tempfile::tempdir().expect("tempdir"); + let plugin_name = "p".repeat(MAX_NAME_LEN); + let skill_name = "s".repeat(MAX_NAME_LEN); + let plugin_root = root.path().join("plugins").join(&plugin_name); + let frontmatter = format!("name: {skill_name}\ndescription: search sample data"); + write_raw_skill_at(&plugin_root.join("skills"), "sample-search", &frontmatter); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + ) + .unwrap(); + + let outcome = load_skills_from_roots([SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("sample@test".to_string()), + plugin_root: Some(plugin_root.abs()), + }]) + .await; + + assert_eq!(outcome.skills, Vec::new()); + assert_eq!(outcome.errors.len(), 1); + assert!( + outcome.errors[0].message.contains("invalid qualified name"), + "expected qualified name length error, got: {:?}", + outcome.errors + ); +} + #[tokio::test] async fn loads_short_description_from_metadata() { let codex_home = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6dd85dffc2..b281b44bb7 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -102,6 +102,14 @@ "additionalProperties": false, "description": "Config values for a single app/connector.", "properties": { + "approvals_reviewer": { + "allOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + } + ], + "description": "Reviewer for approval prompts from this app, overriding the thread default." + }, "default_tools_approval_mode": { "allOf": [ { diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index eecc46d587..821611e60e 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -5,6 +5,7 @@ use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::resolve_role_config; use crate::agent::status::is_final; use crate::codex_thread::ThreadConfigSnapshot; +use crate::config::Config; use crate::session::emit_subagent_session_started; use crate::session_prefix::format_subagent_context_line; use crate::session_prefix::format_subagent_notification_message; @@ -12,7 +13,6 @@ use crate::shell_snapshot::ShellSnapshot; use crate::thread_manager::ResumeThreadWithHistoryOptions; use crate::thread_manager::ThreadManagerState; use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns; -use codex_features::Feature; use codex_protocol::AgentPath; use codex_protocol::SessionId; use codex_protocol::ThreadId; @@ -23,6 +23,7 @@ use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::Op; use codex_protocol::protocol::ResumedHistory; use codex_protocol::protocol::RolloutItem; @@ -58,6 +59,11 @@ pub(crate) struct SpawnAgentOptions { pub(crate) environments: Option>, } +struct SpawnAgentThreadInheritance { + shell_snapshot: Option>, + exec_policy: Option>, +} + #[derive(Clone, Debug)] pub(crate) struct LiveAgent { pub(crate) thread_id: ThreadId, @@ -80,10 +86,7 @@ fn default_agent_nickname_list() -> Vec<&'static str> { .collect() } -fn agent_nickname_candidates( - config: &crate::config::Config, - role_name: Option<&str>, -) -> Vec { +fn agent_nickname_candidates(config: &Config, role_name: Option<&str>) -> Vec { let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); if let Some(candidates) = resolve_role_config(config, role_name).and_then(|role| role.nickname_candidates.clone()) @@ -185,7 +188,7 @@ impl AgentControl { #[cfg(test)] pub(crate) async fn spawn_agent( &self, - config: crate::config::Config, + config: Config, initial_operation: Op, session_source: Option, ) -> CodexResult { @@ -202,7 +205,7 @@ impl AgentControl { /// Spawn an agent thread with some metadata. pub(crate) async fn spawn_agent_with_metadata( &self, - config: crate::config::Config, + config: Config, initial_operation: Op, session_source: Option, options: SpawnAgentOptions, // TODO(jif) drop with new fork. @@ -213,19 +216,33 @@ impl AgentControl { async fn spawn_agent_internal( &self, - config: crate::config::Config, + config: Config, initial_operation: Op, session_source: Option, options: SpawnAgentOptions, ) -> CodexResult { let state = self.upgrade()?; - let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; - let inherited_shell_snapshot = self - .inherited_shell_snapshot_for_source(&state, session_source.as_ref()) - .await; - let inherited_exec_policy = self - .inherited_exec_policy_for_source(&state, session_source.as_ref(), &config) + let multi_agent_version = state + .effective_multi_agent_version_for_spawn( + &InitialHistory::New, + session_source.as_ref(), + options.parent_thread_id, + /*forked_from_thread_id*/ None, + &config, + ) .await; + let agent_max_threads = config + .effective_agent_max_threads(multi_agent_version) + .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; + let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; + let inheritance = SpawnAgentThreadInheritance { + shell_snapshot: self + .inherited_shell_snapshot_for_source(&state, session_source.as_ref()) + .await, + exec_policy: self + .inherited_exec_policy_for_source(&state, session_source.as_ref(), &config) + .await, + }; let (session_source, mut agent_metadata) = match session_source { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, @@ -250,19 +267,19 @@ impl AgentControl { let notification_source = session_source.clone(); // The same `AgentControl` is sent to spawn the thread. - let new_thread = match (session_source, options.fork_mode.as_ref()) { - (Some(session_source), Some(_)) => { + let new_thread = match (session_source, options.fork_mode.as_ref(), inheritance) { + (Some(session_source), Some(_), inheritance) => { Box::pin(self.spawn_forked_thread( &state, config, session_source, &options, - inherited_shell_snapshot, - inherited_exec_policy, + inheritance, + multi_agent_version, )) .await? } - (Some(session_source), None) => { + (Some(session_source), None, inheritance) => { Box::pin(state.spawn_new_thread_with_source( config.clone(), self.clone(), @@ -271,13 +288,13 @@ impl AgentControl { /*forked_from_thread_id*/ None, /*thread_source*/ Some(ThreadSource::Subagent), /*metrics_service_name*/ None, - inherited_shell_snapshot, - inherited_exec_policy, + inheritance.shell_snapshot, + inheritance.exec_policy, options.environments.clone(), )) .await? } - (None, _) => Box::pin(state.spawn_new_thread(config.clone(), self.clone())).await?, + (None, _, _) => Box::pin(state.spawn_new_thread(config.clone(), self.clone())).await?, }; agent_metadata.agent_id = Some(new_thread.thread_id); reservation.commit(agent_metadata.clone()); @@ -340,7 +357,7 @@ impl AgentControl { self.send_input(new_thread.thread_id, initial_operation) .await?; - if !new_thread.thread.enabled(Feature::MultiAgentV2) { + if multi_agent_version != MultiAgentVersion::V2 { let child_reference = agent_metadata .agent_path .as_ref() @@ -364,12 +381,16 @@ impl AgentControl { async fn spawn_forked_thread( &self, state: &Arc, - config: crate::config::Config, + config: Config, session_source: SessionSource, options: &SpawnAgentOptions, - inherited_shell_snapshot: Option>, - inherited_exec_policy: Option>, + inheritance: SpawnAgentThreadInheritance, + multi_agent_version: MultiAgentVersion, ) -> CodexResult { + let SpawnAgentThreadInheritance { + shell_snapshot: inherited_shell_snapshot, + exec_policy: inherited_exec_policy, + } = inheritance; if options.fork_parent_spawn_call_id.is_none() { return Err(CodexErr::Fatal( "spawn_agent fork requires a parent spawn call id".to_string(), @@ -419,7 +440,7 @@ impl AgentControl { } let multi_agent_v2_usage_hint_texts_to_filter: Vec = if let Some(parent_thread) = parent_thread.as_ref() { - if parent_thread.enabled(Feature::MultiAgentV2) { + if multi_agent_version == MultiAgentVersion::V2 { let parent_config = parent_thread.codex.session.get_config().await; [ parent_config @@ -437,7 +458,7 @@ impl AgentControl { } else { Vec::new() } - } else if config.features.enabled(Feature::MultiAgentV2) { + } else if multi_agent_version == MultiAgentVersion::V2 { [ config.multi_agent_v2.root_agent_usage_hint_text.clone(), config.multi_agent_v2.subagent_usage_hint_text.clone(), @@ -473,7 +494,7 @@ impl AgentControl { } } if preserve_reference_context_item - && config.features.enabled(Feature::MultiAgentV2) + && multi_agent_version == MultiAgentVersion::V2 && config.multi_agent_v2.usage_hint_enabled && let Some(subagent_usage_hint_text) = config.multi_agent_v2.subagent_usage_hint_text.clone() @@ -504,7 +525,7 @@ impl AgentControl { /// Resume an existing agent thread from a recorded rollout file. pub(crate) async fn resume_agent_from_rollout( &self, - config: crate::config::Config, + config: Config, thread_id: ThreadId, session_source: SessionSource, ) -> CodexResult { @@ -579,20 +600,42 @@ impl AgentControl { async fn resume_single_agent_from_rollout( &self, - mut config: crate::config::Config, + config: Config, thread_id: ThreadId, session_source: SessionSource, ) -> CodexResult { - if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = &session_source - && *depth >= config.agent_max_depth - && !config.features.enabled(Feature::MultiAgentV2) - { - let _ = config.features.disable(Feature::SpawnCsv); - let _ = config.features.disable(Feature::Collab); - } let state = self.upgrade()?; let state_db_ctx = state.state_db(); - let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; + let stored_thread = state + .read_stored_thread(ReadThreadParams { + thread_id, + include_archived: true, + include_history: true, + }) + .await?; + let history = stored_thread + .history + .ok_or_else(|| CodexErr::ThreadNotFound(thread_id))? + .items; + let initial_history = InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history, + rollout_path: stored_thread.rollout_path, + }); + let parent_thread_id = stored_thread.parent_thread_id; + let multi_agent_version = state + .effective_multi_agent_version_for_spawn( + &initial_history, + Some(&session_source), + parent_thread_id, + /*forked_from_thread_id*/ None, + &config, + ) + .await; + let agent_max_threads = config + .effective_agent_max_threads(multi_agent_version) + .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; + let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; let (session_source, agent_metadata) = match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, @@ -629,27 +672,11 @@ impl AgentControl { let inherited_exec_policy = self .inherited_exec_policy_for_source(&state, Some(&session_source), &config) .await; - let stored_thread = state - .read_stored_thread(ReadThreadParams { - thread_id, - include_archived: true, - include_history: true, - }) - .await?; - let history = stored_thread - .history - .ok_or_else(|| CodexErr::ThreadNotFound(thread_id))? - .items; - let parent_thread_id = stored_thread.parent_thread_id; let resumed_thread = state .resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions { config: config.clone(), - initial_history: InitialHistory::Resumed(ResumedHistory { - conversation_id: thread_id, - history, - rollout_path: stored_thread.rollout_path, - }), + initial_history, agent_control: self.clone(), session_source, parent_thread_id, @@ -663,7 +690,7 @@ impl AgentControl { // Resumed threads are re-registered in-memory and need the same listener // attachment path as freshly spawned threads. state.notify_thread_created(resumed_thread.thread_id); - if !resumed_thread.thread.enabled(Feature::MultiAgentV2) { + if multi_agent_version != MultiAgentVersion::V2 { let child_reference = agent_metadata .agent_path .as_ref() @@ -1048,12 +1075,13 @@ impl AgentControl { }; let child_thread = state.get_thread(child_thread_id).await.ok(); let message = format_subagent_notification_message(child_reference.as_str(), &status); - if child_agent_path.is_some() - && child_thread - .as_ref() - .map(|thread| thread.enabled(Feature::MultiAgentV2)) - .unwrap_or(true) - { + let child_uses_multi_agent_v2 = match child_thread.as_ref() { + Some(child_thread) => { + child_thread.multi_agent_version() == Some(MultiAgentVersion::V2) + } + None => true, + }; + if child_agent_path.is_some() && child_uses_multi_agent_v2 { let Some(child_agent_path) = child_agent_path.clone() else { return; }; @@ -1089,7 +1117,7 @@ impl AgentControl { fn prepare_thread_spawn( &self, reservation: &mut crate::agent::registry::SpawnReservation, - config: &crate::config::Config, + config: &Config, parent_thread_id: ThreadId, depth: i32, agent_path: Option, @@ -1151,7 +1179,7 @@ impl AgentControl { &self, state: &Arc, session_source: Option<&SessionSource>, - child_config: &crate::config::Config, + child_config: &Config, ) -> Option> { let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, .. diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 3e0242c037..f9462e94f6 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1587,9 +1587,15 @@ async fn spawn_child_completion_notifies_parent_history() { #[tokio::test] async fn multi_agent_v2_completion_ignores_dead_direct_parent() { let harness = AgentControlHarness::new().await; - let (root_thread_id, root_thread) = harness.start_thread().await; let mut config = harness.config.clone(); let _ = config.features.enable(Feature::MultiAgentV2); + let root = harness + .manager + .start_thread(config.clone()) + .await + .expect("root thread should start"); + let root_thread_id = root.thread_id; + let root_thread = root.thread; let worker_path = AgentPath::root().join("worker_a").expect("worker path"); let worker_thread_id = harness .control diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 74854b4005..678a6e5d10 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -34,6 +34,7 @@ use crate::config::Config; use crate::guardian::GuardianApprovalRequest; use crate::guardian::new_guardian_review_id; use crate::guardian::routes_approval_to_guardian; +use crate::guardian::routes_approval_to_guardian_with_reviewer; use crate::guardian::spawn_approval_request_review; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION; @@ -41,6 +42,7 @@ use crate::mcp_tool_call::MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC; use crate::mcp_tool_call::build_guardian_mcp_tool_review_request; use crate::mcp_tool_call::is_mcp_tool_approval_question_id; use crate::mcp_tool_call::lookup_mcp_tool_metadata; +use crate::mcp_tool_call::mcp_approvals_reviewer; use crate::session::Codex; use crate::session::CodexSpawnArgs; use crate::session::CodexSpawnOk; @@ -52,6 +54,7 @@ use codex_login::AuthManager; use codex_models_manager::manager::SharedModelsManager; use codex_protocol::error::CodexErr; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::MultiAgentVersion; #[cfg(test)] use crate::session::completed_session_loop_termination; @@ -103,6 +106,7 @@ pub(crate) async fn run_codex_thread_interactive( analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), thread_store: Arc::clone(&parent_session.services.thread_store), attestation_provider: parent_session.services.attestation_provider.clone(), + inherited_multi_agent_version: Some(MultiAgentVersion::Disabled), })) .or_cancel(&cancel_token) .await??; @@ -630,15 +634,14 @@ async fn handle_request_user_input( event: RequestUserInputEvent, cancel_token: &CancellationToken, ) { - if routes_approval_to_guardian(parent_ctx) - && let Some(response) = maybe_auto_review_mcp_request_user_input( - parent_session, - parent_ctx, - pending_mcp_invocations, - &event, - cancel_token, - ) - .await + if let Some(response) = maybe_auto_review_mcp_request_user_input( + parent_session, + parent_ctx, + pending_mcp_invocations, + &event, + cancel_token, + ) + .await { let _ = codex.submit(Op::UserInputAnswer { id, response }).await; return; @@ -692,6 +695,11 @@ async fn maybe_auto_review_mcp_request_user_input( &invocation.tool, ) .await; + let approvals_reviewer = + mcp_approvals_reviewer(parent_ctx, &invocation.server, metadata.as_ref()); + if !routes_approval_to_guardian_with_reviewer(parent_ctx, approvals_reviewer) { + return None; + } let review_cancel = cancel_token.child_token(); let review_rx = spawn_approval_request_review( Arc::clone(parent_session), @@ -747,6 +755,7 @@ async fn handle_request_permissions( ) { let call_id = event.call_id; let args = RequestPermissionsArgs { + 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 04f6ae3965..7dabb09d33 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX; use async_channel::bounded; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::ResponseItem; @@ -182,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); @@ -227,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 { @@ -251,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 @@ -445,3 +450,72 @@ async fn delegated_mcp_guardian_abort_returns_synthetic_decline_answer() { }) ); } + +#[tokio::test] +async fn delegated_mcp_user_reviewer_waits_for_metadata_lookup() { + let (parent_session, parent_ctx, _rx_events) = + crate::session::tests::make_session_and_context_with_rx().await; + let pending_mcp_invocations = Arc::new(Mutex::new(HashMap::from([( + "call-1".to_string(), + McpInvocation { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool: "dangerous_tool".to_string(), + arguments: None, + }, + )]))); + let cancel_token = CancellationToken::new(); + let manager = Arc::clone(&parent_session.services.mcp_connection_manager); + let (manager_locked_tx, manager_locked_rx) = std::sync::mpsc::sync_channel(0); + let (release_manager_tx, release_manager_rx) = std::sync::mpsc::sync_channel(0); + let manager_lock = tokio::task::spawn_blocking(move || { + let _manager_guard = manager.blocking_write(); + manager_locked_tx + .send(()) + .expect("manager lock receiver should remain open"); + release_manager_rx + .recv() + .expect("manager lock release sender should remain open"); + }); + manager_locked_rx + .recv_timeout(Duration::from_secs(1)) + .expect("manager write lock should be acquired"); + + let event = RequestUserInputEvent { + call_id: "call-1".to_string(), + turn_id: "child-turn-1".to_string(), + questions: vec![RequestUserInputQuestion { + id: format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_call-1"), + header: "Approve app tool call?".to_string(), + question: "Allow this app tool?".to_string(), + is_other: false, + is_secret: false, + options: None, + }], + }; + let response = maybe_auto_review_mcp_request_user_input( + &parent_session, + &parent_ctx, + &pending_mcp_invocations, + &event, + &cancel_token, + ); + tokio::pin!(response); + assert!( + timeout(Duration::from_millis(100), &mut response) + .await + .is_err(), + "manual reviewer should wait for MCP metadata" + ); + release_manager_tx + .send(()) + .expect("manager lock holder should remain open"); + manager_lock + .await + .expect("manager lock task should not panic"); + assert_eq!( + timeout(Duration::from_secs(1), response) + .await + .expect("manual reviewer should finish after MCP metadata lookup"), + None + ); +} diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 7a6bc69c5c..cd90829c0c 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -24,6 +24,7 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AdditionalContextEntry; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; @@ -502,6 +503,10 @@ impl CodexThread { self.codex.session.get_config().await } + pub fn multi_agent_version(&self) -> Option { + self.codex.session.multi_agent_version() + } + /// Refresh the thread's layer-backed user config state from a caller-supplied /// config snapshot. Thread-scoped layers and session-static settings remain /// unchanged. 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/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 70719843ba..8bc33bbb45 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -32,7 +32,6 @@ use codex_config::permissions_toml::NetworkToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; use codex_config::permissions_toml::WorkspaceRootsToml; -use codex_config::profile_toml::ConfigProfile; use codex_config::types::AppToolApproval; use codex_config::types::ApprovalsReviewer; use codex_config::types::BundledSkillsConfig; @@ -86,6 +85,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::RealtimeVoice; use codex_protocol::protocol::SandboxPolicy; @@ -103,6 +103,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; @@ -163,7 +164,6 @@ fn http_mcp(url: &str) -> McpServerConfig { async fn derive_legacy_sandbox_policy_for_test( cfg: &ConfigToml, sandbox_mode_override: Option, - profile_sandbox_mode: Option, windows_sandbox_level: WindowsSandboxLevel, active_project: Option<&ProjectConfig>, permission_profile_constraint: Option<&Constrained>, @@ -171,7 +171,6 @@ async fn derive_legacy_sandbox_policy_for_test( let permission_profile = cfg .derive_permission_profile( sandbox_mode_override, - profile_sandbox_mode, windows_sandbox_level, active_project, permission_profile_constraint, @@ -1388,15 +1387,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?; @@ -1866,7 +1864,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 { @@ -2356,7 +2354,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() @@ -2383,6 +2387,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 @@ -2508,6 +2548,65 @@ async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std:: Ok(()) } +#[tokio::test] +async fn empty_config_defaults_to_builtin_profile_for_untrusted_project() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let project_key = cwd.path().to_string_lossy().to_string(); + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + projects: Some(HashMap::from([( + project_key, + ProjectConfig { + trust_level: Some(TrustLevel::Untrusted), + }, + )])), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let policy = config.permissions.file_system_sandbox_policy(); + assert_eq!( + config + .permissions + .active_permission_profile() + .as_ref() + .map(|active| active.id.as_str()), + Some(if cfg!(target_os = "windows") { + BUILT_IN_PERMISSION_PROFILE_READ_ONLY + } else { + BUILT_IN_PERMISSION_PROFILE_WORKSPACE + }) + ); + assert!( + policy.can_read_path_with_cwd(cwd.path(), cwd.path()), + "expected untrusted project fallback to allow reads, policy: {policy:?}" + ); + if cfg!(target_os = "windows") { + assert!( + !policy.can_write_path_with_cwd(cwd.path(), cwd.path()), + "expected untrusted project fallback to stay read-only without Windows sandbox support, policy: {policy:?}" + ); + } else { + assert!( + policy.can_write_path_with_cwd(cwd.path(), cwd.path()), + "expected untrusted project fallback to use :workspace, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(&cwd.path().join(".codex"), cwd.path()), + "expected :workspace metadata carveouts, policy: {policy:?}" + ); + } + Ok(()) +} + #[tokio::test] async fn implicit_builtin_workspace_profile_preserves_sandbox_workspace_write_settings() -> std::io::Result<()> { @@ -3487,7 +3586,6 @@ network_access = false # This should be ignored. let resolution = derive_legacy_sandbox_policy_for_test( &sandbox_full_access_cfg, sandbox_mode_override, - /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, /*active_project*/ None, /*permission_profile_constraint*/ None, @@ -3508,7 +3606,6 @@ network_access = true # This should be ignored. let resolution = derive_legacy_sandbox_policy_for_test( &sandbox_read_only_cfg, sandbox_mode_override, - /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, /*active_project*/ None, /*permission_profile_constraint*/ None, @@ -3540,7 +3637,6 @@ trust_level = "trusted" let resolution = derive_legacy_sandbox_policy_for_test( &sandbox_workspace_write_cfg, sandbox_mode_override, - /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, /*active_project*/ None, /*permission_profile_constraint*/ None, @@ -3580,7 +3676,6 @@ exclude_slash_tmp = true let resolution = derive_legacy_sandbox_policy_for_test( &sandbox_workspace_write_cfg, sandbox_mode_override, - /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, /*active_project*/ None, /*permission_profile_constraint*/ None, @@ -3889,7 +3984,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(), @@ -3939,7 +4034,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(), @@ -4342,7 +4437,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() @@ -4379,27 +4474,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()); @@ -4420,7 +4504,10 @@ enabled = true Some(( false, Some(McpServerDisabledReason::Requirements { - source: RequirementSource::CloudRequirements, + source: RequirementSource::EnterpriseManaged { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + }, }) )) ); @@ -4461,15 +4548,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()); @@ -4483,7 +4570,10 @@ enabled = true Some(( false, Some(McpServerDisabledReason::Requirements { - source: RequirementSource::CloudRequirements, + source: RequirementSource::EnterpriseManaged { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + }, }) )) ); @@ -4960,38 +5050,6 @@ model = "gpt-project-local" Ok(()) } -#[tokio::test] -async fn unselected_profile_sandbox_mode_is_ignored() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - let mut profiles = HashMap::new(); - profiles.insert( - "work".to_string(), - ConfigProfile { - sandbox_mode: Some(SandboxMode::DangerFullAccess), - ..Default::default() - }, - ); - let cfg = ConfigToml { - profiles, - sandbox_mode: Some(SandboxMode::ReadOnly), - ..Default::default() - }; - - let config = Config::load_from_base_config_with_overrides( - cfg, - ConfigOverrides::default(), - codex_home.abs(), - ) - .await?; - - assert_eq!( - config.legacy_sandbox_policy(), - SandboxPolicy::new_read_only_policy() - ); - - Ok(()) -} - #[tokio::test] async fn feature_table_overrides_legacy_flags() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -5101,7 +5159,6 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { Some(cwd), &Vec::new(), overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -5235,7 +5292,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?; @@ -8551,7 +8607,6 @@ trust_level = "untrusted" let resolution = derive_legacy_sandbox_policy_for_test( &cfg, /*sandbox_mode_override*/ None, - /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, Some(&active_project), /*permission_profile_constraint*/ None, @@ -8608,7 +8663,6 @@ async fn derive_sandbox_policy_falls_back_to_read_only_for_implicit_defaults() - let resolution = derive_legacy_sandbox_policy_for_test( &cfg, /*sandbox_mode_override*/ None, - /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, Some(&active_project), Some(&constrained), @@ -8661,7 +8715,6 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb let resolution = derive_legacy_sandbox_policy_for_test( &cfg, /*sandbox_mode_override*/ None, - /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, Some(&active_project), Some(&constrained), @@ -8957,12 +9010,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!( @@ -8981,35 +9033,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!( @@ -9029,21 +9060,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?; @@ -9074,12 +9100,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"); @@ -9109,12 +9134,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"); @@ -9131,11 +9155,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())) @@ -9143,9 +9162,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?; @@ -9161,11 +9182,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())) @@ -9173,9 +9189,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?; @@ -9285,14 +9303,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?; @@ -9326,12 +9341,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?; @@ -9355,12 +9369,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!( @@ -9376,17 +9389,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?; @@ -9410,14 +9421,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?; @@ -9432,17 +9443,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?; @@ -9538,17 +9547,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?; @@ -9658,12 +9665,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?; @@ -9684,12 +9690,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?; @@ -9705,6 +9710,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<()> { @@ -9718,15 +9754,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?; @@ -9801,7 +9833,13 @@ non_code_mode_only = true assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 2500); assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 120000); assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 30000); - assert_eq!(config.agent_max_threads, Some(4)); + assert_eq!( + ( + config.agent_max_threads, + config.effective_agent_max_threads(MultiAgentVersion::V2)? + ), + (None, Some(4)) + ); assert!(!config.multi_agent_v2.usage_hint_enabled); assert_eq!( config.multi_agent_v2.usage_hint_text.as_deref(), @@ -9845,7 +9883,13 @@ enabled = true assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 10_000); assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 3_600_000); assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 30_000); - assert_eq!(config.agent_max_threads, Some(3)); + assert_eq!( + ( + config.agent_max_threads, + config.effective_agent_max_threads(MultiAgentVersion::V2)? + ), + (None, Some(3)) + ); assert_eq!( config.multi_agent_v2.root_agent_usage_hint_text.as_deref(), Some(DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT) @@ -9912,17 +9956,19 @@ max_threads = 3 "#, )?; - let err = ConfigBuilder::without_managed_config_for_tests() + 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())) .build() - .await + .await?; + let err = config + .effective_agent_max_threads(MultiAgentVersion::V2) .expect_err("agents.max_threads should conflict with multi_agent_v2"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), - "agents.max_threads cannot be set when multi_agent_v2 is enabled" + "agents.max_threads cannot be set when the multi-agent runtime is v2" ); Ok(()) @@ -10181,7 +10227,13 @@ max_concurrent_threads_per_session = 1 .await?; assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 1); - assert_eq!(config.agent_max_threads, Some(0)); + assert_eq!( + ( + config.agent_max_threads, + config.effective_agent_max_threads(MultiAgentVersion::V2)? + ), + (None, Some(0)) + ); Ok(()) } @@ -10192,17 +10244,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?; @@ -10228,14 +10278,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?; @@ -10258,14 +10308,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 ff666b51e9..fd71cea987 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; @@ -99,6 +99,7 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; @@ -823,7 +824,7 @@ pub struct Config { /// Token budget applied when storing tool/function outputs in the context manager. pub tool_output_token_limit: Option, - /// Maximum number of agent threads that can be open concurrently. + /// User-configured maximum number of agent threads that can be open concurrently. pub agent_max_threads: Option, /// Maximum runtime in seconds for agent job workers before they are failed. pub agent_job_max_runtime_seconds: Option, @@ -1111,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, } @@ -1142,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 } @@ -1172,7 +1173,7 @@ impl ConfigBuilder { harness_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, thread_config_loader, fallback_cwd, } = self; @@ -1197,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), @@ -1281,6 +1282,40 @@ impl ConfigBuilder { } impl Config { + pub(crate) fn multi_agent_version_from_features(&self) -> MultiAgentVersion { + if self.features.enabled(Feature::MultiAgentV2) { + MultiAgentVersion::V2 + } else if self.features.enabled(Feature::Collab) { + MultiAgentVersion::V1 + } else { + MultiAgentVersion::Disabled + } + } + + pub(crate) fn effective_agent_max_threads( + &self, + multi_agent_version: MultiAgentVersion, + ) -> std::io::Result> { + match multi_agent_version { + MultiAgentVersion::V2 => { + if self.agent_max_threads.is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agents.max_threads cannot be set when the multi-agent runtime is v2", + )); + } + Ok(Some( + self.multi_agent_v2 + .max_concurrent_threads_per_session + .saturating_sub(1), + )) + } + MultiAgentVersion::Disabled | MultiAgentVersion::V1 => { + Ok(self.agent_max_threads.or(DEFAULT_AGENT_MAX_THREADS)) + } + } + } + pub fn legacy_sandbox_policy(&self) -> SandboxPolicy { self.permissions.legacy_sandbox_policy(self.cwd.as_path()) } @@ -1588,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?; @@ -1799,7 +1833,6 @@ pub async fn load_global_mcp_servers( cwd, &cli_overrides, LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2903,7 +2936,6 @@ impl Config { let mut permission_profile = cfg .derive_permission_profile( sandbox_mode, - /*profile_sandbox_mode*/ None, windows_sandbox_level, Some(&active_project), Some(&constrained_permission_profile), @@ -3063,29 +3095,13 @@ impl Config { )); } validate_multi_agent_v2_tool_namespace(multi_agent_v2.tool_namespace.as_deref())?; - let agent_max_threads_from_config = cfg.agents.as_ref().and_then(|agents| agents.max_threads); - let agent_max_threads = if features.enabled(Feature::MultiAgentV2) { - if agent_max_threads_from_config.is_some() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "agents.max_threads cannot be set when multi_agent_v2 is enabled", - )); - } - Some( - multi_agent_v2 - .max_concurrent_threads_per_session - .saturating_sub(1), - ) - } else { - let agent_max_threads = agent_max_threads_from_config.or(DEFAULT_AGENT_MAX_THREADS); - if agent_max_threads == Some(0) { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "agents.max_threads must be at least 1", - )); - } - agent_max_threads - }; + let agent_max_threads = cfg.agents.as_ref().and_then(|agents| agents.max_threads); + if agent_max_threads == Some(0) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agents.max_threads must be at least 1", + )); + } let agent_max_depth = cfg .agents .as_ref() 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/connectors.rs b/codex-rs/core/src/connectors.rs index 55a941e8c4..7a8344b3d7 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -26,6 +26,7 @@ use crate::plugins::list_tool_suggest_discoverable_plugins; use crate::session::INITIAL_SUBMIT_ID; use codex_config::AppsRequirementsToml; use codex_config::types::AppToolApproval; +use codex_config::types::ApprovalsReviewer; use codex_config::types::AppsConfigToml; use codex_config::types::ToolSuggestDiscoverableType; use codex_core_plugins::PluginsManager; @@ -111,6 +112,7 @@ pub(crate) async fn list_accessible_and_enabled_connectors_from_manager( pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( config: &Config, + plugins_manager: &PluginsManager, auth: Option<&CodexAuth>, accessible_connectors: &[AppInfo], loaded_plugin_app_connector_ids: &[String], @@ -129,11 +131,15 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( ) .into_iter() .map(DiscoverableTool::from); - let discoverable_plugins = - list_tool_suggest_discoverable_plugins(config, loaded_plugin_app_connector_ids) - .await? - .into_iter() - .map(DiscoverableTool::from); + let discoverable_plugins = list_tool_suggest_discoverable_plugins( + config, + plugins_manager, + auth, + loaded_plugin_app_connector_ids, + ) + .await? + .into_iter() + .map(DiscoverableTool::from); Ok(discoverable_connectors .chain(discoverable_plugins) .collect()) @@ -566,6 +572,35 @@ pub(crate) fn codex_app_tool_is_enabled(config: &Config, tool_info: &ToolInfo) - .enabled } +pub(crate) fn mcp_approvals_reviewer( + config: &Config, + server_name: &str, + connector_id: Option<&str>, +) -> ApprovalsReviewer { + let app_reviewer = if server_name == CODEX_APPS_MCP_SERVER_NAME { + read_user_apps_config(config).and_then(|apps_config| { + connector_id + .and_then(|connector_id| apps_config.apps.get(connector_id)) + .and_then(|app| app.approvals_reviewer) + }) + } else { + None + }; + + if let Some(reviewer) = app_reviewer + && config + .config_layer_stack + .requirements() + .approvals_reviewer + .can_set(&reviewer) + .is_ok() + { + return reviewer; + } + + config.approvals_reviewer +} + fn read_apps_config(config: &Config) -> Option { let apps_config = read_user_apps_config(config); let had_apps_config = apps_config.is_some(); diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 285df9a29a..0427ce5a4a 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -5,13 +5,14 @@ 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; +use codex_config::types::ApprovalsReviewer; use codex_config::types::AppsDefaultConfig; use codex_connectors::merge::plugin_connector_to_app_info; use codex_connectors::metadata::connector_install_url; @@ -377,6 +378,7 @@ fn app_is_enabled_prefers_per_app_override_over_default() { "calendar".to_string(), AppConfig { enabled: true, + approvals_reviewer: None, destructive_enabled: None, open_world_enabled: None, default_tools_approval_mode: None, @@ -390,6 +392,86 @@ fn app_is_enabled_prefers_per_app_override_over_default() { assert!(!app_is_enabled(&apps_config, Some("drive"))); } +#[tokio::test] +async fn app_approvals_reviewer_overrides_global_reviewer() { + for (global, app, expected_global, expected_app) in [ + ( + "user", + "auto_review", + ApprovalsReviewer::User, + ApprovalsReviewer::AutoReview, + ), + ( + "auto_review", + "user", + ApprovalsReviewer::AutoReview, + ApprovalsReviewer::User, + ), + ] { + let codex_home = tempdir().expect("tempdir should succeed"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#" +approvals_reviewer = "{global}" + +[apps.calendar] +approvals_reviewer = "{app}" +"# + ), + ) + .expect("write config"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config should build"); + + assert_eq!( + mcp_approvals_reviewer(&config, CODEX_APPS_MCP_SERVER_NAME, Some("calendar")), + expected_app + ); + assert_eq!( + mcp_approvals_reviewer(&config, CODEX_APPS_MCP_SERVER_NAME, Some("drive")), + expected_global + ); + assert_eq!( + mcp_approvals_reviewer(&config, "custom_server", Some("calendar")), + expected_global + ); + } +} + +#[tokio::test] +async fn app_approvals_reviewer_respects_global_reviewer_requirements() { + let codex_home = tempdir().expect("tempdir should succeed"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +approvals_reviewer = "auto_review" + +[apps.calendar] +approvals_reviewer = "user" +"#, + ) + .expect("write config"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["auto_review"]"#, + ), + ) + .build() + .await + .expect("config should build"); + + assert_eq!( + mcp_approvals_reviewer(&config, CODEX_APPS_MCP_SERVER_NAME, Some("calendar")), + ApprovalsReviewer::AutoReview + ); +} + #[test] fn requirements_disabled_connector_overrides_enabled_connector() { let mut effective_apps = AppsConfigToml { @@ -457,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), @@ -468,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"); @@ -508,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"); @@ -786,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), @@ -797,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"); @@ -932,6 +994,7 @@ fn app_tool_policy_allows_per_app_enable_when_default_is_disabled() { "calendar".to_string(), AppConfig { enabled: true, + approvals_reviewer: None, destructive_enabled: None, open_world_enabled: None, default_tools_approval_mode: None, @@ -969,6 +1032,7 @@ fn app_tool_policy_per_tool_enabled_true_overrides_app_level_disable_flags() { "calendar".to_string(), AppConfig { enabled: true, + approvals_reviewer: None, destructive_enabled: Some(false), open_world_enabled: Some(false), default_tools_approval_mode: None, @@ -1012,6 +1076,7 @@ fn app_tool_policy_default_tools_enabled_true_overrides_app_level_tool_hints() { "calendar".to_string(), AppConfig { enabled: true, + approvals_reviewer: None, destructive_enabled: Some(false), open_world_enabled: Some(false), default_tools_approval_mode: None, @@ -1047,6 +1112,7 @@ fn app_tool_policy_default_tools_enabled_false_overrides_app_level_tool_hints() "calendar".to_string(), AppConfig { enabled: true, + approvals_reviewer: None, destructive_enabled: Some(true), open_world_enabled: Some(true), default_tools_approval_mode: Some(AppToolApproval::Approve), @@ -1084,6 +1150,7 @@ fn app_tool_policy_uses_default_tools_approval_mode() { "calendar".to_string(), AppConfig { enabled: true, + approvals_reviewer: None, destructive_enabled: None, open_world_enabled: None, default_tools_approval_mode: Some(AppToolApproval::Prompt), @@ -1123,6 +1190,7 @@ fn app_tool_policy_matches_prefix_stripped_tool_name_for_tool_config() { "calendar".to_string(), AppConfig { enabled: true, + approvals_reviewer: None, destructive_enabled: Some(false), open_world_enabled: Some(false), default_tools_approval_mode: Some(AppToolApproval::Auto), @@ -1236,11 +1304,17 @@ discoverables = [ .await .expect("config should load"); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf()); - let discoverable_tools = - list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[], &[]) - .await - .expect("discoverable tools should load"); + let discoverable_tools = list_tool_suggest_discoverable_tools_with_auth( + &config, + &plugins_manager, + Some(&auth), + &[], + &[], + ) + .await + .expect("discoverable tools should load"); assert_eq!( discoverable_tools, @@ -1268,9 +1342,11 @@ apps = true .expect("config should load"); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let loaded_plugin_app_connector_ids = vec!["asdk_app_databricks_workspace".to_string()]; + let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf()); let discoverable_tools = list_tool_suggest_discoverable_tools_with_auth( &config, + &plugins_manager, Some(&auth), &[], &loaded_plugin_app_connector_ids, diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/environment_context_tests.rs index 875ce20c09..49c3fc0c58 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/environment_context_tests.rs @@ -180,6 +180,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() { model: "gpt-5".to_string(), personality: None, collaboration_mode: None, + multi_agent_version: None, realtime_active: None, effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 42179c42b5..7ee22be44a 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -132,6 +132,7 @@ fn reference_context_item() -> TurnContextItem { model: "gpt-test".to_string(), personality: None, collaboration_mode: None, + multi_agent_version: None, realtime_active: Some(false), effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 8cc04e5c42..b4920f1ff6 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -39,6 +39,7 @@ pub(crate) use review::review_approval_request; #[cfg(test)] pub(crate) use review::review_approval_request_with_cancel; pub(crate) use review::routes_approval_to_guardian; +pub(crate) use review::routes_approval_to_guardian_with_reviewer; pub(crate) use review::spawn_approval_request_review; pub(crate) use review_session::GuardianReviewSessionManager; pub(crate) use review_session::prompt_cache_key_override_for_review_session; diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 244d6e09e0..167b153e20 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -145,10 +145,18 @@ fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { /// reviewer instead of surfacing them to the user. ARC may still block actions /// earlier in the flow. pub(crate) fn routes_approval_to_guardian(turn: &TurnContext) -> bool { + routes_approval_to_guardian_with_reviewer(turn, turn.config.approvals_reviewer) +} + +/// Whether an approval with its own reviewer selection should be routed through guardian. +pub(crate) fn routes_approval_to_guardian_with_reviewer( + turn: &TurnContext, + approvals_reviewer: ApprovalsReviewer, +) -> bool { matches!( turn.approval_policy.value(), AskForApproval::OnRequest | AskForApproval::Granular(_) - ) && turn.config.approvals_reviewer == ApprovalsReviewer::AutoReview + ) && approvals_reviewer == ApprovalsReviewer::AutoReview } pub(crate) fn is_guardian_reviewer_source( 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, diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 40a07d3802..aba67d8500 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -1099,6 +1099,20 @@ async fn routes_approval_to_guardian_requires_guardian_reviewer() { assert!(routes_approval_to_guardian(&turn)); } +#[tokio::test] +async fn routes_approval_to_guardian_can_use_app_reviewer_override() { + let (_session, turn) = crate::session::tests::make_session_and_context().await; + + assert!(!routes_approval_to_guardian_with_reviewer( + &turn, + ApprovalsReviewer::User + )); + assert!(routes_approval_to_guardian_with_reviewer( + &turn, + ApprovalsReviewer::AutoReview + )); +} + #[tokio::test] async fn routes_approval_to_guardian_allows_granular_review_policy() { let (_session, mut turn) = crate::session::tests::make_session_and_context().await; diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index 79048a78e0..93bf22ca9f 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -34,7 +34,7 @@ where P: AsRef, { let network_sandbox_policy = permission_profile.network_sandbox_policy(); - let mitm_ca_cert_path = network.and_then(NetworkProxy::mitm_ca_cert_path); + let mitm_ca_cert_path = network.and_then(NetworkProxy::managed_mitm_ca_trust_bundle_path); let args = create_linux_sandbox_command_args_for_permission_profile( command, command_cwd.as_path(), diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index e514fa0d03..9c00ba2be6 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -13,7 +13,7 @@ use crate::guardian::guardian_rejection_message; use crate::guardian::guardian_timeout_message; use crate::guardian::new_guardian_review_id; use crate::guardian::review_approval_request; -use crate::guardian::routes_approval_to_guardian; +use crate::guardian::routes_approval_to_guardian_with_reviewer; use crate::hook_runtime::run_permission_request_hooks; use crate::mcp_openai_file::rewrite_mcp_tool_arguments_for_openai_files; use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam; @@ -32,6 +32,7 @@ use codex_app_server_protocol::McpElicitationSchema; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_config::types::AppToolApproval; +use codex_config::types::ApprovalsReviewer; use codex_features::Feature; use codex_hooks::PermissionRequestDecision; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; @@ -1162,11 +1163,11 @@ async fn maybe_request_mcp_tool_approval( metadata: Option<&McpToolApprovalMetadata>, approval_mode: AppToolApproval, ) -> Option { + let approvals_reviewer = mcp_approvals_reviewer(turn_context, &invocation.server, metadata); if mcp_permission_prompt_is_auto_approved( turn_context.approval_policy.value(), &turn_context.permission_profile(), McpPermissionPromptAutoApproveContext { - approvals_reviewer: Some(turn_context.config.approvals_reviewer), tool_approval_mode: Some(approval_mode), }, ) { @@ -1218,7 +1219,7 @@ async fn maybe_request_mcp_tool_approval( .features .enabled(Feature::ToolCallMcpElicitation); - if routes_approval_to_guardian(turn_context) { + if routes_approval_to_guardian_with_reviewer(turn_context, approvals_reviewer) { let review_id = new_guardian_review_id(); let decision = review_approval_request( sess, @@ -1328,6 +1329,18 @@ async fn maybe_request_mcp_tool_approval( Some(decision) } +pub(crate) fn mcp_approvals_reviewer( + turn_context: &TurnContext, + server_name: &str, + metadata: Option<&McpToolApprovalMetadata>, +) -> ApprovalsReviewer { + connectors::mcp_approvals_reviewer( + turn_context.config.as_ref(), + server_name, + metadata.and_then(|metadata| metadata.connector_id.as_deref()), + ) +} + fn session_mcp_tool_approval_key( invocation: &McpInvocation, metadata: Option<&McpToolApprovalMetadata>, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 534c2f0118..c9259b8f2d 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1886,6 +1886,7 @@ async fn persist_codex_app_tool_approval_writes_tool_override() { "calendar".to_string(), AppConfig { enabled: true, + approvals_reviewer: None, destructive_enabled: None, open_world_enabled: None, default_tools_approval_mode: None, diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 7d67ad7859..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 Result io::R base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }; diff --git a/codex-rs/core/src/plugins/discoverable.rs b/codex-rs/core/src/plugins/discoverable.rs index 4f0d88b512..8ddec7056e 100644 --- a/codex-rs/core/src/plugins/discoverable.rs +++ b/codex-rs/core/src/plugins/discoverable.rs @@ -1,124 +1,54 @@ -use anyhow::Context; -use std::collections::HashSet; -use tracing::warn; - -use super::PluginCapabilitySummary; use crate::config::Config; use codex_config::types::ToolSuggestDiscoverableType; -use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; -use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core_plugins::PluginsManager; -use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST as TOOL_SUGGEST_DISCOVERABLE_PLUGIN_FALLBACK_ALLOWLIST; -use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy; -use codex_features::Feature; +use codex_core_plugins::ToolSuggestPluginDiscoveryInput; +use codex_login::CodexAuth; use codex_tools::DiscoverablePluginInfo; - -const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[ - OPENAI_BUNDLED_MARKETPLACE_NAME, - OPENAI_CURATED_MARKETPLACE_NAME, -]; +use std::collections::HashSet; pub(crate) async fn list_tool_suggest_discoverable_plugins( config: &Config, + plugins_manager: &PluginsManager, + auth: Option<&CodexAuth>, loaded_plugin_app_connector_ids: &[String], ) -> anyhow::Result> { - if !config.features.enabled(Feature::Plugins) { - return Ok(Vec::new()); - } - - let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf()); - let plugins_input = config.plugins_config_input(); - let configured_plugin_ids = config - .tool_suggest - .discoverables - .iter() - .filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Plugin) - .map(|discoverable| discoverable.id.as_str()) - .collect::>(); - let disabled_plugin_ids = config - .tool_suggest - .disabled_tools - .iter() - .filter(|disabled_tool| disabled_tool.kind == ToolSuggestDiscoverableType::Plugin) - .map(|disabled_tool| disabled_tool.id.as_str()) - .collect::>(); - let marketplaces = plugins_manager - .list_marketplaces_for_config(&plugins_input, &[]) - .context("failed to list plugin marketplaces for tool suggestions")? - .marketplaces; - let mut installed_app_connector_ids = plugins_manager - .plugins_for_config(&plugins_input) + let input = ToolSuggestPluginDiscoveryInput { + plugins: config.plugins_config_input(), + configured_plugin_ids: config + .tool_suggest + .discoverables + .iter() + .filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Plugin) + .map(|discoverable| discoverable.id.clone()) + .collect::>(), + disabled_plugin_ids: config + .tool_suggest + .disabled_tools + .iter() + .filter(|disabled_tool| disabled_tool.kind == ToolSuggestDiscoverableType::Plugin) + .map(|disabled_tool| disabled_tool.id.clone()) + .collect::>(), + loaded_plugin_app_connector_ids: loaded_plugin_app_connector_ids + .iter() + .cloned() + .collect::>(), + }; + plugins_manager + .list_tool_suggest_discoverable_plugins(&input, auth) .await - .capability_summaries() - .iter() - .flat_map(|plugin| plugin.app_connector_ids.iter()) - .map(|connector_id| connector_id.0.clone()) - .collect::>(); - installed_app_connector_ids.extend(loaded_plugin_app_connector_ids.iter().cloned()); - - let mut discoverable_plugins = Vec::::new(); - for marketplace in marketplaces { - let marketplace_name = marketplace.name; - let is_allowlisted_marketplace = - TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST.contains(&marketplace_name.as_str()); - - for plugin in marketplace.plugins { - let is_configured_plugin = configured_plugin_ids.contains(plugin.id.as_str()); - let is_fallback_plugin = - TOOL_SUGGEST_DISCOVERABLE_PLUGIN_FALLBACK_ALLOWLIST.contains(&plugin.id.as_str()); - if plugin.installed - || plugin.policy.installation == MarketplacePluginInstallPolicy::NotAvailable - || disabled_plugin_ids.contains(plugin.id.as_str()) - || (!is_allowlisted_marketplace && !is_configured_plugin) - { - continue; - } - - let plugin_id = plugin.id.clone(); - - match plugins_manager - .read_plugin_detail_for_marketplace_plugin( - &plugins_input, - &marketplace_name, - plugin, - ) - .await - { - Ok(plugin) => { - let plugin: PluginCapabilitySummary = plugin.into(); - let matches_installed_app = - plugin.app_connector_ids.iter().any(|connector_id| { - installed_app_connector_ids.contains(connector_id.0.as_str()) - }); - if !is_configured_plugin && !is_fallback_plugin && !matches_installed_app { - continue; - } - - discoverable_plugins.push(DiscoverablePluginInfo { - id: plugin.config_name, - name: plugin.display_name, - description: plugin.description, - has_skills: plugin.has_skills, - mcp_server_names: plugin.mcp_server_names, - app_connector_ids: plugin - .app_connector_ids - .into_iter() - .map(|connector_id| connector_id.0) - .collect(), - }); - } - Err(err) => { - warn!("failed to load discoverable plugin suggestion {plugin_id}: {err:#}") - } - } - } - } - discoverable_plugins.sort_by(|left, right| { - left.name - .cmp(&right.name) - .then_with(|| left.id.cmp(&right.id)) - }); - Ok(discoverable_plugins) + .map(|plugins| { + plugins + .into_iter() + .map(|plugin| DiscoverablePluginInfo { + id: plugin.id, + name: plugin.name, + description: plugin.description, + has_skills: plugin.has_skills, + mcp_server_names: plugin.mcp_server_names, + app_connector_ids: plugin.app_connector_ids, + }) + .collect() + }) } #[cfg(test)] diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index 21203aea23..992bf93f45 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -1,4 +1,3 @@ -use super::*; use crate::plugins::test_support::load_plugins_config; use crate::plugins::test_support::write_curated_plugin; use crate::plugins::test_support::write_curated_plugin_sha; @@ -7,6 +6,10 @@ use crate::plugins::test_support::write_openai_curated_marketplace; use crate::plugins::test_support::write_plugins_feature_config; use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; use codex_core_plugins::PluginInstallRequest; +use codex_core_plugins::PluginsManager; +use codex_core_plugins::remote::RemotePluginScope; +use codex_core_plugins::remote::RemotePluginServiceConfig; +use codex_core_plugins::remote::fetch_and_cache_global_remote_plugin_catalog; use codex_core_plugins::startup_sync::curated_plugins_repo_path; use codex_tools::DiscoverablePluginInfo; use codex_utils_absolute_path::AbsolutePathBuf; @@ -17,6 +20,44 @@ use tracing::Level; use tracing_subscriber::fmt::format::FmtSpan; use tracing_test::internal::MockWriter; +async fn list_discoverable_plugins( + config: &crate::config::Config, + loaded_plugin_app_connector_ids: &[String], +) -> anyhow::Result> { + list_discoverable_plugins_with_auth(config, /*auth*/ None, loaded_plugin_app_connector_ids) + .await +} + +async fn list_discoverable_plugins_with_auth( + config: &crate::config::Config, + auth: Option<&codex_login::CodexAuth>, + loaded_plugin_app_connector_ids: &[String], +) -> anyhow::Result> { + let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf()); + list_discoverable_plugins_with_manager_and_auth( + config, + &plugins_manager, + auth, + loaded_plugin_app_connector_ids, + ) + .await +} + +async fn list_discoverable_plugins_with_manager_and_auth( + config: &crate::config::Config, + plugins_manager: &PluginsManager, + auth: Option<&codex_login::CodexAuth>, + loaded_plugin_app_connector_ids: &[String], +) -> anyhow::Result> { + super::list_tool_suggest_discoverable_plugins( + config, + plugins_manager, + auth, + loaded_plugin_app_connector_ids, + ) + .await +} + #[tokio::test] async fn list_tool_suggest_discoverable_plugins_returns_fallback_plugins_without_installed_apps() { let codex_home = tempdir().expect("tempdir should succeed"); @@ -25,9 +66,7 @@ async fn list_tool_suggest_discoverable_plugins_returns_fallback_plugins_without write_plugins_feature_config(codex_home.path()); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!( discoverable_plugins @@ -51,9 +90,7 @@ async fn list_tool_suggest_discoverable_plugins_filters_non_fallback_by_installe install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await; let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!( discoverable_plugins @@ -76,10 +113,9 @@ async fn list_tool_suggest_discoverable_plugins_filters_by_loaded_plugin_apps() write_plugins_feature_config(codex_home.path()); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = - list_tool_suggest_discoverable_plugins(&config, &[hubspot_app_id.to_string()]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[hubspot_app_id.to_string()]) + .await + .unwrap(); assert_eq!( discoverable_plugins @@ -102,9 +138,7 @@ async fn list_tool_suggest_discoverable_plugins_filters_microsoft_by_installed_a install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "teams").await; let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!( discoverable_plugins @@ -119,6 +153,283 @@ async fn list_tool_suggest_discoverable_plugins_filters_microsoft_by_installed_a ); } +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_includes_cached_remote_global_plugins() { + use codex_login::CodexAuth; + use serde_json::json; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + use wiremock::matchers::query_param; + + let codex_home = tempdir().expect("tempdir should succeed"); + write_file( + &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", "GLOBAL")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [ + { + "id": "plugins~Plugin_remote_github", + "name": "github", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "AVAILABLE", + "release": { + "display_name": "Remote GitHub", + "description": "Remote GitHub long", + "app_ids": ["github"], + "interface": { + "short_description": "Remote GitHub short", + "long_description": null, + "developer_name": null, + "category": null, + "capabilities": [], + "website_url": null, + "privacy_policy_url": null, + "terms_of_service_url": null, + "brand_color": null, + "default_prompt": null, + "composer_icon_url": null, + "logo_url": null, + "screenshot_urls": [] + }, + "skills": [ + { + "name": "github", + "description": "Use GitHub", + "interface": null + } + ] + } + }, + { + "id": "plugins~Plugin_remote_unlisted", + "name": "remote-unlisted", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "AVAILABLE", + "release": { + "display_name": "Remote Unlisted", + "description": "Remote Unlisted long", + "app_ids": [], + "interface": { + "short_description": "Remote Unlisted short", + "long_description": null, + "developer_name": null, + "category": null, + "capabilities": [], + "website_url": null, + "privacy_policy_url": null, + "terms_of_service_url": null, + "brand_color": null, + "default_prompt": null, + "composer_icon_url": null, + "logo_url": null, + "screenshot_urls": [] + }, + "skills": [ + { + "name": "remote-unlisted", + "description": "Use unlisted remote plugin", + "interface": null + } + ] + } + }, + { + "id": "plugins~Plugin_remote_slack_not_available", + "name": "slack", + "scope": "GLOBAL", + "installation_policy": "NOT_AVAILABLE", + "authentication_policy": "ON_USE", + "status": "AVAILABLE", + "release": { + "display_name": "Remote Slack", + "description": "Remote Slack long", + "app_ids": [], + "interface": { + "short_description": "Remote Slack short", + "long_description": null, + "developer_name": null, + "category": null, + "capabilities": [], + "website_url": null, + "privacy_policy_url": null, + "terms_of_service_url": null, + "brand_color": null, + "default_prompt": null, + "composer_icon_url": null, + "logo_url": null, + "screenshot_urls": [] + }, + "skills": [] + } + }, + { + "id": "plugins~Plugin_remote_figma_admin_disabled", + "name": "figma", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "DISABLED_BY_ADMIN", + "release": { + "display_name": "Remote Figma", + "description": "Remote Figma long", + "app_ids": [], + "interface": { + "short_description": "Remote Figma short", + "long_description": null, + "developer_name": null, + "category": null, + "capabilities": [], + "website_url": null, + "privacy_policy_url": null, + "terms_of_service_url": null, + "brand_color": null, + "default_prompt": null, + "composer_icon_url": null, + "logo_url": null, + "screenshot_urls": [] + }, + "skills": [] + } + } + ], + "pagination": { + "next_page_token": null + } + }))) + .expect(1) + .mount(&server) + .await; + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let mut config = load_plugins_config(codex_home.path()).await; + config.chatgpt_base_url = format!("{}/backend-api", server.uri()); + let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf()); + fetch_and_cache_global_remote_plugin_catalog( + codex_home.path(), + &RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }, + Some(&auth), + ) + .await + .expect("remote plugin catalog cache should write"); + + let discoverable_plugins = list_discoverable_plugins_with_manager_and_auth( + &config, + &plugins_manager, + Some(&auth), + &[], + ) + .await + .unwrap(); + assert!( + discoverable_plugins + .iter() + .all(|plugin| plugin.id != "github@openai-curated-remote") + ); + + for scope in ["GLOBAL", "WORKSPACE"] { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", scope)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [], + "pagination": { + "next_page_token": null + } + }))) + .expect(1) + .mount(&server) + .await; + } + plugins_manager + .build_and_cache_remote_installed_plugin_marketplaces( + &config.plugins_config_input(), + Some(&auth), + &[RemotePluginScope::Global], + /*on_effective_plugins_changed*/ None, + ) + .await + .expect("remote installed plugin cache should write"); + + let discoverable_plugins = list_discoverable_plugins_with_manager_and_auth( + &config, + &plugins_manager, + Some(&auth), + &[], + ) + .await + .unwrap(); + assert_eq!( + discoverable_plugins + .iter() + .filter(|plugin| plugin.id.ends_with("@openai-curated-remote")) + .map(|plugin| plugin.id.as_str()) + .collect::>(), + vec!["github@openai-curated-remote"] + ); + let remote_plugins = discoverable_plugins + .into_iter() + .filter(|plugin| plugin.id == "github@openai-curated-remote") + .collect::>(); + + assert_eq!( + remote_plugins, + vec![DiscoverablePluginInfo { + id: "github@openai-curated-remote".to_string(), + name: "Remote GitHub".to_string(), + description: Some("Remote GitHub short".to_string()), + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: vec!["github".to_string()], + }] + ); + + write_file( + &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true + +[tool_suggest] +disabled_tools = [ + { type = "plugin", id = "github@openai-curated-remote" } +] +"#, + ); + let mut config_with_disabled_remote_plugin = load_plugins_config(codex_home.path()).await; + config_with_disabled_remote_plugin.chatgpt_base_url = config.chatgpt_base_url.clone(); + let discoverable_plugins = list_discoverable_plugins_with_manager_and_auth( + &config_with_disabled_remote_plugin, + &plugins_manager, + Some(&auth), + &[], + ) + .await + .unwrap(); + assert!( + discoverable_plugins + .iter() + .all(|plugin| plugin.id != "github@openai-curated-remote") + ); +} + #[tokio::test] async fn list_tool_suggest_discoverable_plugins_filters_sales_apps_by_marketplace() { let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14"; @@ -179,9 +490,7 @@ source = "/tmp/{sales_marketplace_name}" install_marketplace_plugin(codex_home.path(), sales_marketplace_root.as_path(), "sales").await; let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!( discoverable_plugins @@ -195,6 +504,55 @@ source = "/tmp/{sales_marketplace_name}" ); } +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_omits_openai_curated_when_remote_enabled() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + + let bundled_marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME; + let bundled_marketplace_root = codex_home + .path() + .join(format!(".tmp/marketplaces/{bundled_marketplace_name}")); + write_file( + &bundled_marketplace_root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "{bundled_marketplace_name}", + "plugins": [ + {{"name": "chrome", "source": {{"source": "local", "path": "./plugins/chrome"}}}} + ] +}} +"# + ), + ); + write_curated_plugin(&bundled_marketplace_root, "chrome"); + write_file( + &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + &format!( + r#"[features] +plugins = true +remote_plugin = true + +[marketplaces.{bundled_marketplace_name}] +source_type = "git" +source = "/tmp/{bundled_marketplace_name}" +"# + ), + ); + + let config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); + + assert_eq!( + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec!["chrome@openai-bundled".to_string()] + ); +} + #[tokio::test] async fn list_tool_suggest_discoverable_plugins_deduplicates_configured_marketplace_plugin() { let codex_home = tempdir().expect("tempdir should succeed"); @@ -234,9 +592,7 @@ discoverables = [{{ type = "plugin", id = "{plugin_id}" }}] ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!(discoverable_plugins.len(), 1); assert_eq!(discoverable_plugins[0].id, plugin_id.as_str()); @@ -278,9 +634,7 @@ source = "/tmp/{marketplace_name}" install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!(discoverable_plugins.len(), 1); assert_eq!(discoverable_plugins[0].id, "slack@openai-curated"); @@ -299,9 +653,7 @@ plugins = false ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!(discoverable_plugins, Vec::::new()); } @@ -322,9 +674,7 @@ async fn list_tool_suggest_discoverable_plugins_normalizes_description() { install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!( discoverable_plugins, @@ -359,7 +709,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins( .expect("plugin should install"); let refreshed_config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config, &[]) + let discoverable_plugins = list_discoverable_plugins(&refreshed_config, &[]) .await .unwrap(); @@ -384,9 +734,7 @@ disabled_tools = [ ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!(discoverable_plugins, Vec::::new()); } @@ -435,9 +783,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_not_available_curated_plug install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!( discoverable_plugins @@ -464,9 +810,7 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }] ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!( discoverable_plugins, @@ -519,9 +863,7 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_ .finish(); let _guard = tracing::subscriber::set_default(subscriber); - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) - .await - .unwrap(); + let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap(); assert_eq!( discoverable_plugins diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index a775c8b836..cc018343eb 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -455,7 +455,15 @@ async fn review_guardian_mcp_elicitation( return Ok(None); }; - if !crate::guardian::routes_approval_to_guardian(turn_context.as_ref()) { + let approvals_reviewer = crate::connectors::mcp_approvals_reviewer( + turn_context.config.as_ref(), + request.server_name.as_str(), + elicitation_connector_id(&request.elicitation), + ); + if !crate::guardian::routes_approval_to_guardian_with_reviewer( + turn_context.as_ref(), + approvals_reviewer, + ) { return Ok(None); } @@ -567,6 +575,15 @@ fn guardian_elicitation_review_request( )) } +fn elicitation_connector_id(elicitation: &CreateElicitationRequestParams) -> Option<&str> { + match elicitation { + CreateElicitationRequestParams::FormElicitationParams { meta, .. } + | CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => meta + .as_ref() + .and_then(|meta| metadata_str(&meta.0, MCP_ELICITATION_CONNECTOR_ID_KEY)), + } +} + fn meta_requests_approval_request(meta: &Option) -> bool { meta.as_ref() .and_then(|meta| metadata_str(&meta.0, MCP_ELICITATION_REQUEST_TYPE_KEY)) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index b796c15d61..a4ae153da8 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; @@ -105,6 +106,7 @@ use codex_protocol::protocol::HasLegacyEvent; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::ItemStartedEvent; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::RawResponseItemEvent; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; @@ -418,6 +420,25 @@ pub(crate) struct CodexSpawnArgs { pub(crate) analytics_events_client: Option, pub(crate) thread_store: Arc, pub(crate) attestation_provider: Option>, + pub(crate) inherited_multi_agent_version: Option, +} + +pub(crate) fn resolve_multi_agent_version( + conversation_history: &InitialHistory, + inherited_multi_agent_version: Option, +) -> Option { + if inherited_multi_agent_version == Some(MultiAgentVersion::Disabled) { + return Some(MultiAgentVersion::Disabled); + } + + conversation_history + .get_multi_agent_version() + .or(inherited_multi_agent_version) + .or(match conversation_history { + InitialHistory::New | InitialHistory::Cleared => None, + // Threads created before runtime metadata existed keep the legacy V1 tool surface. + InitialHistory::Resumed(_) | InitialHistory::Forked(_) => Some(MultiAgentVersion::V1), + }) } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -479,18 +500,11 @@ impl Codex { analytics_events_client, thread_store, attestation_provider, + inherited_multi_agent_version, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); - if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = session_source - && depth >= config.agent_max_depth - && !config.features.enabled(Feature::MultiAgentV2) - { - let _ = config.features.disable(Feature::SpawnCsv); - let _ = config.features.disable(Feature::Collab); - } - let primary_environment = environment_selections.primary_environment(); let mut user_instruction_warnings = Vec::new(); let user_instructions = AgentsMdManager::new(&config) @@ -541,6 +555,14 @@ impl Codex { let model_info = models_manager .get_model_info(model.as_str(), &config.to_models_manager_config()) .await; + let multi_agent_version = + resolve_multi_agent_version(&conversation_history, inherited_multi_agent_version); + let startup_multi_agent_version = multi_agent_version + .or(model_info.multi_agent_version) + .unwrap_or_else(|| config.multi_agent_version_from_features()); + let _ = config + .effective_agent_max_threads(startup_multi_agent_version) + .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; let base_instructions = config .base_instructions .clone() @@ -604,7 +626,7 @@ impl Codex { let session_source_clone = session_configuration.session_source.clone(); let (agent_status_tx, agent_status_rx) = watch::channel(AgentStatus::PendingInit); - let session = Session::new( + let session = Box::pin(Session::new( session_configuration, config.clone(), installation_id, @@ -625,7 +647,8 @@ impl Codex { thread_store, parent_rollout_thread_trace, attestation_provider, - ) + multi_agent_version, + )) .await .map_err(|e| { error!("Failed to create session: {e:#}"); @@ -1260,7 +1283,6 @@ impl Session { } async fn record_initial_history(&self, conversation_history: InitialHistory) { - let turn_context = self.new_default_turn().await; let is_subagent = { let state = self.state.lock().await; state @@ -1281,6 +1303,7 @@ impl Session { .await; } InitialHistory::Resumed(resumed_history) => { + let turn_context = self.new_default_turn().await; let rollout_items = resumed_history.history; let previous_turn_settings = self .apply_rollout_reconstruction(&turn_context, &rollout_items) @@ -1320,6 +1343,7 @@ impl Session { } } InitialHistory::Forked(rollout_items) => { + let turn_context = self.new_default_turn().await; self.apply_rollout_reconstruction(&turn_context, &rollout_items) .await; @@ -1708,6 +1732,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(); @@ -1745,7 +1780,7 @@ impl Session { turn_context: &TurnContext, msg: &EventMsg, ) { - if !self.enabled(Feature::MultiAgentV2) { + if turn_context.multi_agent_version != MultiAgentVersion::V2 { return; } @@ -2174,34 +2209,16 @@ impl Session { rx_approve } - pub async fn request_permissions( - self: &Arc, - turn_context: &Arc, - call_id: String, - args: RequestPermissionsArgs, - cancellation_token: CancellationToken, - ) -> Option { - self.request_permissions_for_cwd( - turn_context, - call_id, - args, - #[allow(deprecated)] - turn_context.cwd.clone(), - cancellation_token, - ) - .await - } - #[expect( 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() { @@ -2295,10 +2312,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; @@ -2316,7 +2334,7 @@ impl Session { PendingRequestPermissions { tx_response, requested_permissions: requested_permissions.clone(), - cwd: cwd.clone(), + environment: environment.clone(), }, ) } @@ -2330,10 +2348,11 @@ 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, - cwd: Some(cwd), + cwd: Some(environment.cwd), }); self.send_event(turn_context.as_ref(), event).await; tokio::select! { @@ -2350,6 +2369,41 @@ 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 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 = turn_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" @@ -2444,10 +2498,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; @@ -2491,6 +2546,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() { @@ -2502,7 +2558,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(); } @@ -2510,7 +2566,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(), + ); } } } @@ -2519,11 +2578,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( @@ -2539,9 +2601,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( @@ -2716,6 +2781,33 @@ impl Session { state.session_configuration.collaboration_mode.clone() } + pub(crate) fn multi_agent_version(&self) -> Option { + self.multi_agent_version.get().copied() + } + + pub(crate) fn set_multi_agent_version_if_unset( + &self, + multi_agent_version: MultiAgentVersion, + ) -> MultiAgentVersion { + *self.multi_agent_version.get_or_init(|| multi_agent_version) + } + + pub(crate) fn resolve_multi_agent_version_for_model( + &self, + model_info: &ModelInfo, + config: &Config, + ) -> MultiAgentVersion { + if let Some(multi_agent_version) = self.multi_agent_version() { + return multi_agent_version; + } + + let selected = model_info + .multi_agent_version + .unwrap_or_else(|| config.multi_agent_version_from_features()); + + self.set_multi_agent_version_if_unset(selected) + } + async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) { for item in items { self.send_event( diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs index eb3da0da4b..a78a9c938e 100644 --- a/codex-rs/core/src/session/multi_agents.rs +++ b/codex-rs/core/src/session/multi_agents.rs @@ -1,5 +1,5 @@ use crate::session::turn_context::TurnContext; -use codex_features::Feature; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; @@ -7,7 +7,7 @@ pub(super) fn usage_hint_text<'a>( turn_context: &'a TurnContext, session_source: &SessionSource, ) -> Option<&'a str> { - if !turn_context.features.enabled(Feature::MultiAgentV2) { + if turn_context.multi_agent_version != MultiAgentVersion::V2 { return None; } diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index c476436560..fe78e033ec 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -128,6 +128,7 @@ pub(super) async fn spawn_review_thread( user_instructions: None, compact_prompt: parent_turn_context.compact_prompt.clone(), collaboration_mode: parent_turn_context.collaboration_mode.clone(), + multi_agent_version: MultiAgentVersion::Disabled, personality: parent_turn_context.personality, approval_policy: parent_turn_context.approval_policy.clone(), permission_profile: parent_turn_context.permission_profile(), diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 05be3e8967..0ca54e55e0 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -72,6 +72,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -110,6 +111,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -959,6 +961,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1037,6 +1040,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1065,6 +1069,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1183,6 +1188,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo model: current_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1300,6 +1306,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -1458,6 +1465,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 45d7924c95..a8f309198d 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -9,8 +9,10 @@ use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; +use std::sync::OnceLock; use tokio::sync::Semaphore; /// Context for an initialized model agent @@ -29,6 +31,7 @@ pub(crate) struct Session { /// The set of enabled features should be invariant for the lifetime of the /// session. pub(super) features: ManagedFeatures, + pub(super) multi_agent_version: OnceLock, pub(super) pending_mcp_server_refresh_config: Mutex>, pub(crate) conversation: Arc, pub(crate) active_turn: Mutex>, @@ -503,6 +506,7 @@ impl Session { thread_store: Arc, parent_rollout_thread_trace: ThreadTraceContext, attestation_provider: Option>, + multi_agent_version: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -517,6 +521,8 @@ impl Session { .parent_thread_id .or_else(|| initial_history.get_resumed_parent_thread_id()); session_configuration.parent_thread_id = parent_thread_id; + let multi_agent_version = multi_agent_version.map(OnceLock::from).unwrap_or_default(); + let initial_multi_agent_version = multi_agent_version.get().copied(); let thread_id = match &initial_history { InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => { @@ -558,6 +564,7 @@ impl Session { text: session_configuration.base_instructions.clone(), }, dynamic_tools: session_configuration.dynamic_tools.clone(), + multi_agent_version: initial_multi_agent_version, metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), model_provider: config.model_provider_id.clone(), @@ -1075,6 +1082,7 @@ impl Session { state: Mutex::new(state), managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1), features: config.features.clone(), + multi_agent_version, pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 045069d16e..8ed96975d0 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; @@ -112,6 +113,7 @@ use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::NetworkApprovalProtocol; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; @@ -121,6 +123,8 @@ use codex_protocol::protocol::RealtimeVoice; use codex_protocol::protocol::RealtimeVoicesList; use codex_protocol::protocol::ResumedHistory; use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::Submission; use codex_protocol::protocol::ThreadGoalStatus; @@ -176,6 +180,7 @@ use serde::Deserialize; use serde_json::json; use std::path::PathBuf; use std::sync::Arc; +use std::sync::OnceLock; use std::time::Duration as StdDuration; mod guardian_tests; @@ -1043,7 +1048,7 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> 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 { @@ -1653,6 +1658,72 @@ async fn record_initial_history_reconstructs_resumed_transcript() { assert_eq!(expected, history.raw_items()); } +#[test] +fn resolve_multi_agent_version_handles_unset_and_legacy_history() { + let thread_id = ThreadId::default(); + + assert_eq!( + resolve_multi_agent_version( + &InitialHistory::New, + /*inherited_multi_agent_version*/ None + ), + None + ); + assert_eq!( + resolve_multi_agent_version( + &InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history: Vec::new(), + rollout_path: None, + }), + /*inherited_multi_agent_version*/ None, + ), + Some(MultiAgentVersion::V1) + ); + assert_eq!( + resolve_multi_agent_version( + &InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history: Vec::new(), + rollout_path: None, + }), + Some(MultiAgentVersion::V2), + ), + Some(MultiAgentVersion::V2) + ); + assert_eq!( + resolve_multi_agent_version( + &InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history: vec![session_meta_item( + thread_id, + Some(MultiAgentVersion::Disabled) + )], + rollout_path: None, + }), + Some(MultiAgentVersion::V2), + ), + Some(MultiAgentVersion::Disabled) + ); + assert_eq!( + resolve_multi_agent_version( + &InitialHistory::Forked(vec![session_meta_item( + thread_id, + Some(MultiAgentVersion::V2) + )]), + Some(MultiAgentVersion::Disabled), + ), + Some(MultiAgentVersion::Disabled) + ); + assert_eq!( + resolve_multi_agent_version( + &InitialHistory::Forked(Vec::new()), + /*inherited_multi_agent_version*/ None + ), + Some(MultiAgentVersion::V1) + ); +} + #[tokio::test] async fn record_initial_history_new_defers_initial_context_until_first_turn() { let (session, _turn_context) = make_session_and_context().await; @@ -1665,6 +1736,20 @@ async fn record_initial_history_new_defers_initial_context_until_first_turn() { assert_eq!(session.previous_turn_settings().await, None); } +fn session_meta_item( + thread_id: ThreadId, + multi_agent_version: Option, +) -> RolloutItem { + RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + id: thread_id, + multi_agent_version, + ..SessionMeta::default() + }, + git: None, + }) +} + #[tokio::test] async fn resumed_history_injects_initial_context_on_first_context_update_only() { let (session, turn_context) = make_session_and_context().await; @@ -2479,6 +2564,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { model: previous_model.to_string(), personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), + multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), effort: turn_context.reasoning_effort, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -3126,6 +3212,7 @@ async fn set_rate_limits_retains_previous_credits() { unlimited: false, balance: Some("10.00".to_string()), }), + individual_limit: None, plan_type: Some(codex_protocol::account::PlanType::Plus), rate_limit_reached_type: None, }; @@ -3145,6 +3232,7 @@ async fn set_rate_limits_retains_previous_credits() { resets_at: Some(1_900), }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -3158,6 +3246,7 @@ async fn set_rate_limits_retains_previous_credits() { primary: update.primary.clone(), secondary: update.secondary, credits: initial.credits, + individual_limit: initial.individual_limit, plan_type: initial.plan_type, rate_limit_reached_type: None, }) @@ -3235,6 +3324,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { unlimited: false, balance: Some("15.00".to_string()), }), + individual_limit: None, plan_type: Some(codex_protocol::account::PlanType::Plus), rate_limit_reached_type: None, }; @@ -3250,6 +3340,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { }), secondary: None, credits: None, + individual_limit: None, plan_type: Some(codex_protocol::account::PlanType::Pro), rate_limit_reached_type: None, }; @@ -3263,6 +3354,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { primary: update.primary, secondary: update.secondary, credits: initial.credits, + individual_limit: initial.individual_limit, plan_type: update.plan_type, rate_limit_reached_type: None, }) @@ -3462,6 +3554,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf { thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), + multi_agent_version: None, metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), model_provider: config.model_provider_id.clone(), @@ -4520,6 +4613,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { )), codex_rollout_trace::ThreadTraceContext::disabled(), /*attestation_provider*/ None, + Some(config.multi_agent_version_from_features()), ) .await; @@ -4721,6 +4815,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { &session_telemetry, session_configuration.provider.clone(), &session_configuration, + config.multi_agent_version_from_features(), services.user_shell.as_ref(), services.shell_zsh_path.as_ref(), services.main_execve_wrapper_exe.as_ref(), @@ -4744,6 +4839,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { state: Mutex::new(state), managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1), features: config.features.clone(), + multi_agent_version: OnceLock::from(config.multi_agent_version_from_features()), pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), @@ -4870,6 +4966,7 @@ async fn make_session_with_config_and_rx( )), codex_rollout_trace::ThreadTraceContext::disabled(), /*attestation_provider*/ None, + Some(config.multi_agent_version_from_features()), ) .await?; @@ -4981,6 +5078,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( )), codex_rollout_trace::ThreadTraceContext::disabled(), /*attestation_provider*/ None, + Some(config.multi_agent_version_from_features()), ) .await?; @@ -5069,7 +5167,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] @@ -5096,16 +5199,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] @@ -5128,6 +5299,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; @@ -5204,11 +5376,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 { @@ -5217,6 +5395,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() ..RequestPermissionProfile::default() }, }, + environment, CancellationToken::new(), ) .await @@ -5231,6 +5410,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)); @@ -5247,6 +5430,142 @@ 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_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 = { + #[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].environment_id = "remote".to_string(); + 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!({ + "environment_id": "remote", + "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.environment_id.as_deref(), Some("remote")); + 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_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; @@ -5285,14 +5604,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 @@ -5306,6 +5632,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 @@ -5339,7 +5669,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()) ); } @@ -5363,11 +5695,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 { @@ -5376,6 +5714,7 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req ..RequestPermissionProfile::default() }, }, + environment, CancellationToken::new(), ) .await; @@ -5961,6 +6300,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), + multi_agent_version: None, metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), model_provider: config.model_provider_id.clone(), @@ -6568,6 +6908,7 @@ where &session_telemetry, session_configuration.provider.clone(), &session_configuration, + config.multi_agent_version_from_features(), services.user_shell.as_ref(), services.shell_zsh_path.as_ref(), services.main_execve_wrapper_exe.as_ref(), @@ -6591,6 +6932,7 @@ where state: Mutex::new(state), managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1), features: config.features.clone(), + multi_agent_version: OnceLock::from(config.multi_agent_version_from_features()), pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), @@ -6773,7 +7115,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 @@ -10364,7 +10706,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 cd2548ba09..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(), ), ) @@ -148,7 +155,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()) ); @@ -211,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 @@ -246,7 +262,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 +401,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 +586,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); @@ -709,6 +734,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { analytics_events_client: None, thread_store, attestation_provider: None, + inherited_multi_agent_version: None, }) .await .expect("spawn guardian subagent"); @@ -728,6 +754,5 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { }], } ); - drop(codex); } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index f808134f99..c6a3155b74 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -69,6 +69,8 @@ use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_async_utils::OrCancelExt; +use codex_extension_api::TurnInputContext; +use codex_extension_api::TurnInputEnvironment; use codex_features::Feature; use codex_git_utils::get_git_repo_root; use codex_git_utils::get_git_repo_root_with_fs; @@ -369,7 +371,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 +382,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 +408,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 @@ -511,6 +515,9 @@ async fn build_skills_and_plugins( }; let skills_outcome = turn_context.turn_skills.outcome.as_ref(); let connector_slug_counts = build_connector_slug_counts(&available_connectors); + let extension_injection_items = + build_extension_turn_input_items(sess, turn_context, &user_input, cancellation_token) + .await?; let skill_name_counts_lower = build_skill_name_counts(&skills_outcome.skills, &skills_outcome.disabled_paths).1; let mentioned_skills = collect_explicit_skill_mentions( @@ -586,9 +593,57 @@ async fn build_skills_and_plugins( let mut injection_items = skill_items; injection_items.extend(plugin_items); + injection_items.extend(extension_injection_items); Some((injection_items, explicitly_enabled_connectors)) } +async fn build_extension_turn_input_items( + sess: &Arc, + turn_context: &TurnContext, + user_input: &[UserInput], + cancellation_token: &CancellationToken, +) -> Option> { + let contributors = sess.services.extensions.turn_input_contributors().to_vec(); + if contributors.is_empty() { + return Some(Vec::new()); + } + + let environments = turn_context + .environments + .turn_environments + .iter() + .enumerate() + .map(|(index, environment)| TurnInputEnvironment { + environment_id: environment.environment_id.clone(), + cwd: environment.cwd.as_path().to_path_buf(), + is_primary: index == 0, + }) + .collect::>(); + + let input = TurnInputContext { + turn_id: turn_context.sub_id.to_string(), + user_input: user_input.to_vec(), + environments, + }; + + let mut items = Vec::new(); + for contributor in contributors { + let contributed_items = contributor + .contribute( + input.clone(), + &sess.services.session_extension_data, + &sess.services.thread_extension_data, + turn_context.extension_data.as_ref(), + ) + .or_cancel(cancellation_token) + .await + .ok()?; + items.extend(contributed_items); + } + + Some(items) +} + async fn track_turn_resolved_config_analytics( sess: &Session, turn_context: &TurnContext, @@ -638,6 +693,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, }); } @@ -1080,6 +1136,7 @@ pub(crate) async fn built_tools( if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() { match connectors::list_tool_suggest_discoverable_tools_with_auth( &turn_context.config, + sess.services.plugins_manager.as_ref(), auth.as_ref(), accessible_connectors.as_slice(), &loaded_plugin_app_connector_ids, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index b36fb870d0..91a2337f9b 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -7,7 +7,9 @@ use codex_model_provider::create_model_provider; use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::models::AdditionalPermissionProfile; +use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ToolMode; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; @@ -78,6 +80,7 @@ pub struct TurnContext { pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, pub(crate) collaboration_mode: CollaborationMode, + pub(crate) multi_agent_version: MultiAgentVersion, pub(crate) personality: Option, pub(crate) approval_policy: Constrained, pub(crate) permission_profile: PermissionProfile, @@ -101,6 +104,12 @@ pub struct TurnContext { pub(crate) server_model_warning_emitted: AtomicBool, pub(crate) model_verification_emitted: AtomicBool, } + +enum TurnMultiAgentRuntime { + ResolveAndStore, + Preview, +} + impl TurnContext { pub(crate) fn permission_profile(&self) -> PermissionProfile { self.permission_profile.clone() @@ -246,6 +255,7 @@ impl TurnContext { compact_prompt: self.compact_prompt.clone(), user_instructions: self.user_instructions.clone(), collaboration_mode, + multi_agent_version: self.multi_agent_version, personality: self.personality, approval_policy: self.approval_policy.clone(), permission_profile: self.permission_profile.clone(), @@ -353,6 +363,7 @@ impl TurnContext { model: self.model_info.slug.clone(), personality: self.personality, collaboration_mode: Some(self.collaboration_mode.clone()), + multi_agent_version: Some(self.multi_agent_version), realtime_active: Some(self.realtime_active), effort: self.reasoning_effort, summary: ReasoningSummaryConfig::Auto, @@ -454,6 +465,7 @@ impl Session { session_telemetry: &SessionTelemetry, provider: ModelProviderInfo, session_configuration: &SessionConfiguration, + multi_agent_version: MultiAgentVersion, user_shell: &shell::Shell, shell_zsh_path: Option<&PathBuf>, main_execve_wrapper_exe: Option<&PathBuf>, @@ -543,6 +555,7 @@ impl Session { compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), collaboration_mode: session_configuration.collaboration_mode.clone(), + multi_agent_version, personality: session_configuration.personality, approval_policy: session_configuration.approval_policy.clone(), permission_profile: session_configuration.permission_profile(), @@ -684,8 +697,43 @@ impl Session { final_output_json_schema: Option>, turn_environments: ResolvedTurnEnvironments, ) -> Arc { - let primary_turn_environment = turn_environments.primary(); + self.new_turn_context_from_configuration( + sub_id, + session_configuration, + final_output_json_schema, + turn_environments, + TurnMultiAgentRuntime::ResolveAndStore, + ) + .await + } + + async fn new_startup_prewarm_turn_from_configuration( + &self, + sub_id: String, + session_configuration: SessionConfiguration, + turn_environments: ResolvedTurnEnvironments, + ) -> Arc { + self.new_turn_context_from_configuration( + sub_id, + session_configuration, + /*final_output_json_schema*/ None, + turn_environments, + TurnMultiAgentRuntime::Preview, + ) + .await + } + + async fn new_turn_context_from_configuration( + &self, + sub_id: String, + session_configuration: SessionConfiguration, + final_output_json_schema: Option>, + turn_environments: ResolvedTurnEnvironments, + multi_agent_runtime: TurnMultiAgentRuntime, + ) -> Arc { + let primary_turn_environment = turn_environments.primary().cloned(); let cwd = primary_turn_environment + .as_ref() .map(|turn_environment| turn_environment.cwd.clone()) .unwrap_or_else(|| session_configuration.cwd.clone()); let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone()); @@ -705,6 +753,16 @@ impl Session { &per_turn_config.to_models_manager_config(), ) .await; + + let multi_agent_version = match multi_agent_runtime { + TurnMultiAgentRuntime::ResolveAndStore => { + self.resolve_multi_agent_version_for_model(&model_info, &per_turn_config) + } + TurnMultiAgentRuntime::Preview => self + .multi_agent_version() + .or(model_info.multi_agent_version) + .unwrap_or_else(|| per_turn_config.multi_agent_version_from_features()), + }; let plugin_outcome = self .services .plugins_manager @@ -728,6 +786,7 @@ impl Session { &self.services.session_telemetry, session_configuration.provider.clone(), &session_configuration, + multi_agent_version, self.services.user_shell.as_ref(), self.services.shell_zsh_path.as_ref(), self.services.main_execve_wrapper_exe.as_ref(), @@ -781,6 +840,34 @@ impl Session { } pub(crate) async fn new_default_turn_with_sub_id(&self, sub_id: String) -> Arc { + let (session_configuration, turn_environments) = + self.default_turn_configuration_and_environments().await; + self.new_turn_from_configuration( + sub_id, + session_configuration, + /*final_output_json_schema*/ None, + turn_environments, + ) + .await + } + + pub(crate) async fn new_startup_prewarm_turn_with_sub_id( + &self, + sub_id: String, + ) -> Arc { + let (session_configuration, turn_environments) = + self.default_turn_configuration_and_environments().await; + self.new_startup_prewarm_turn_from_configuration( + sub_id, + session_configuration, + turn_environments, + ) + .await + } + + async fn default_turn_configuration_and_environments( + &self, + ) -> (SessionConfiguration, ResolvedTurnEnvironments) { let session_configuration = { let state = self.state.lock().await; state.session_configuration.clone() @@ -797,14 +884,7 @@ impl Session { ResolvedTurnEnvironments::default() } }; - - self.new_turn_from_configuration( - sub_id, - session_configuration, - /*final_output_json_schema*/ None, - turn_environments, - ) - .await + (session_configuration, turn_environments) } fn overlay_runtime_cwd_on_primary_environment( diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 0669e54469..d620648179 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -172,6 +172,10 @@ impl SessionStartupPrewarmHandle { impl Session { pub(crate) async fn schedule_startup_prewarm(self: &Arc, base_instructions: String) { + if !self.services.model_client.responses_websocket_enabled() { + return; + } + let session_telemetry = self.services.session_telemetry.clone(); let websocket_connect_timeout = self.provider().await.websocket_connect_timeout(); let started_at = Instant::now(); @@ -222,7 +226,7 @@ async fn schedule_startup_prewarm_inner( ) -> CodexResult { let prewarm_started_at = Instant::now(); let startup_turn_context = session - .new_default_turn_with_sub_id(INITIAL_SUBMIT_ID.to_owned()) + .new_startup_prewarm_turn_with_sub_id(INITIAL_SUBMIT_ID.to_owned()) .await; startup_turn_context.session_telemetry.record_startup_phase( "startup_prewarm_create_turn_context", diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index ac59cb32d5..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() } } @@ -258,6 +275,9 @@ fn merge_rate_limit_fields( if snapshot.credits.is_none() { snapshot.credits = previous.and_then(|prior| prior.credits.clone()); } + if snapshot.individual_limit.is_none() { + snapshot.individual_limit = previous.and_then(|prior| prior.individual_limit.clone()); + } if snapshot.plan_type.is_none() { snapshot.plan_type = previous.and_then(|prior| prior.plan_type); } diff --git a/codex-rs/core/src/state/session_tests.rs b/codex-rs/core/src/state/session_tests.rs index b6b5a057a0..5f0ae085e7 100644 --- a/codex-rs/core/src/state/session_tests.rs +++ b/codex-rs/core/src/state/session_tests.rs @@ -3,6 +3,7 @@ use crate::session::tests::make_session_configuration_for_tests; use crate::state::AutoCompactWindowSnapshot; use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::RateLimitWindow; +use codex_protocol::protocol::SpendControlLimitSnapshot; use pretty_assertions::assert_eq; #[tokio::test] @@ -49,6 +50,7 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }); @@ -95,6 +97,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }); @@ -108,6 +111,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }); @@ -122,7 +126,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc } #[tokio::test] -async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other() { +async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() { let session_configuration = make_session_configuration_for_tests().await; let mut state = SessionState::new(session_configuration); @@ -140,6 +144,12 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other unlimited: false, balance: Some("50".to_string()), }), + individual_limit: Some(SpendControlLimitSnapshot { + limit: "25000".to_string(), + used: "8000".to_string(), + remaining_percent: 68, + resets_at: 300, + }), plan_type: Some(codex_protocol::account::PlanType::Plus), rate_limit_reached_type: None, }); @@ -154,6 +164,7 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }); @@ -174,6 +185,12 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other unlimited: false, balance: Some("50".to_string()), }), + individual_limit: Some(SpendControlLimitSnapshot { + limit: "25000".to_string(), + used: "8000".to_string(), + remaining_percent: 68, + resets_at: 300, + }), plan_type: Some(codex_protocol::account::PlanType::Plus), rate_limit_reached_type: None, }) 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/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/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 38a8a96276..76d5e6478e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -44,6 +44,7 @@ use codex_otel::TURN_TOKEN_USAGE_METRIC; use codex_otel::TURN_TOOL_CALL_METRIC; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; @@ -70,11 +71,14 @@ pub(crate) enum InterruptedTurnHistoryMarker { } impl InterruptedTurnHistoryMarker { - pub(crate) fn from_config(config: &Config) -> Self { + pub(crate) fn from_config_and_version( + config: &Config, + multi_agent_version: MultiAgentVersion, + ) -> Self { if !config.agent_interrupt_message_enabled { return Self::Disabled; } - if config.features.enabled(Feature::MultiAgentV2) { + if multi_agent_version == MultiAgentVersion::V2 { Self::Developer } else { Self::ContextualUser @@ -841,7 +845,10 @@ impl Session { if reason == TurnAbortReason::Interrupted && let Some(marker) = interrupted_turn_history_marker( - InterruptedTurnHistoryMarker::from_config(task.turn_context.config.as_ref()), + InterruptedTurnHistoryMarker::from_config_and_version( + task.turn_context.config.as_ref(), + task.turn_context.multi_agent_version, + ), ) { self.record_conversation_items( diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 3624440b24..d130a35815 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -12,6 +12,7 @@ use crate::session::Codex; use crate::session::CodexSpawnArgs; use crate::session::CodexSpawnOk; use crate::session::INITIAL_SUBMIT_ID; +use crate::session::resolve_multi_agent_version; use crate::shell_snapshot::ShellSnapshot; use crate::tasks::InterruptedTurnHistoryMarker; use crate::tasks::interrupted_turn_history_marker; @@ -38,6 +39,7 @@ use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::Op; use codex_protocol::protocol::ResumedHistory; use codex_protocol::protocol::RolloutItem; @@ -639,10 +641,16 @@ impl ThreadManager { )) })?; let history = stored_thread_to_initial_history(stored_thread, fork_source.rollout_path())?; + let inherited_multi_agent_version = fork_source + .multi_agent_version() + .unwrap_or(MultiAgentVersion::V1); options.initial_history = fork_history_from_snapshot( ForkSnapshot::Interrupted, history, - InterruptedTurnHistoryMarker::from_config(&options.config), + InterruptedTurnHistoryMarker::from_config_and_version( + &options.config, + inherited_multi_agent_version, + ), ); self.start_thread_with_options_and_fork_source(options, Some(forked_from_thread_id)) .await @@ -894,7 +902,18 @@ impl ThreadManager { InitialHistory::Forked(_) => history.forked_from_id(), InitialHistory::New | InitialHistory::Cleared => None, }; - let interrupted_marker = InterruptedTurnHistoryMarker::from_config(&config); + let multi_agent_version = self + .state + .effective_multi_agent_version_for_spawn( + &history, + /*session_source*/ None, + /*parent_thread_id*/ None, + forked_from_thread_id, + &config, + ) + .await; + let interrupted_marker = + InterruptedTurnHistoryMarker::from_config_and_version(&config, multi_agent_version); let history = fork_history_from_snapshot(snapshot, history, interrupted_marker); let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), @@ -1018,6 +1037,52 @@ impl ThreadManagerState { self.threads.write().await.remove(thread_id) } + pub(crate) async fn effective_multi_agent_version_for_spawn( + &self, + initial_history: &InitialHistory, + session_source: Option<&SessionSource>, + parent_thread_id: Option, + forked_from_thread_id: Option, + config: &Config, + ) -> MultiAgentVersion { + self.initial_multi_agent_version_for_spawn( + initial_history, + session_source, + parent_thread_id, + forked_from_thread_id, + ) + .await + .unwrap_or_else(|| config.multi_agent_version_from_features()) + } + + async fn initial_multi_agent_version_for_spawn( + &self, + initial_history: &InitialHistory, + session_source: Option<&SessionSource>, + parent_thread_id: Option, + forked_from_thread_id: Option, + ) -> Option { + let inherited_thread_id = match session_source { + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + })) => Some(*parent_thread_id), + _ => match initial_history { + InitialHistory::Resumed(resumed) => Some(resumed.conversation_id), + InitialHistory::Forked(_) => forked_from_thread_id.or(parent_thread_id), + InitialHistory::New | InitialHistory::Cleared => parent_thread_id, + }, + }; + let inherited_multi_agent_version = match inherited_thread_id { + Some(thread_id) => self + .get_thread(thread_id) + .await + .ok() + .and_then(|thread| thread.multi_agent_version()), + None => None, + }; + resolve_multi_agent_version(initial_history, inherited_multi_agent_version) + } + /// Spawn a new thread with no history using a provided config. pub(crate) async fn spawn_new_thread( &self, @@ -1233,9 +1298,17 @@ impl ThreadManagerState { .parent_rollout_thread_trace_for_source(&session_source, &initial_history) .await; let tracked_session_source = session_source.clone(); + let multi_agent_version = self + .initial_multi_agent_version_for_spawn( + &initial_history, + Some(&session_source), + parent_thread_id, + forked_from_thread_id, + ) + .await; let CodexSpawnOk { codex, thread_id, .. - } = Codex::spawn(CodexSpawnArgs { + } = Box::pin(Codex::spawn(CodexSpawnArgs { config, installation_id: self.installation_id.clone(), auth_manager, @@ -1262,7 +1335,8 @@ impl ThreadManagerState { analytics_events_client: self.analytics_events_client.clone(), thread_store: Arc::clone(&self.thread_store), attestation_provider: self.attestation_provider.clone(), - }) + inherited_multi_agent_version: multi_agent_version, + })) .await?; let new_thread = self .finalize_thread_spawn(codex, thread_id, tracked_session_source) diff --git a/codex-rs/core/src/tools/handlers/agent_jobs.rs b/codex-rs/core/src/tools/handlers/agent_jobs.rs index 821b3619c4..48ba087434 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs.rs @@ -1,6 +1,4 @@ use crate::agent::control::SpawnAgentOptions; -use crate::agent::exceeds_thread_spawn_depth_limit; -use crate::agent::next_thread_spawn_depth; use crate::agent::status::is_final; use crate::config::Config; use crate::function_tool::FunctionCallError; @@ -11,6 +9,7 @@ use crate::tools::handlers::parse_arguments; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; use codex_protocol::protocol::AgentStatus; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; @@ -111,21 +110,22 @@ async fn build_runner_options( turn: &Arc, requested_concurrency: Option, ) -> Result { - let session_source = turn.session_source.clone(); - let child_depth = next_thread_spawn_depth(&session_source); - let max_depth = turn.config.agent_max_depth; - if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { + let multi_agent_version = turn.multi_agent_version; + if multi_agent_version == MultiAgentVersion::Disabled { return Err(FunctionCallError::RespondToModel( - "agent depth limit reached; this session cannot spawn more subagents".to_string(), + "multi-agent runtime is disabled; this session cannot spawn workers".to_string(), )); } - if turn.config.agent_max_threads == Some(0) { + let agent_max_threads = turn + .config + .effective_agent_max_threads(multi_agent_version) + .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; + if agent_max_threads == Some(0) { return Err(FunctionCallError::RespondToModel( "agent thread limit reached; this session cannot spawn more subagents".to_string(), )); } - let max_concurrency = - normalize_concurrency(requested_concurrency, turn.config.agent_max_threads); + let max_concurrency = normalize_concurrency(requested_concurrency, agent_max_threads); let base_instructions = session.get_base_instructions().await; let spawn_config = build_agent_spawn_config(&base_instructions, turn.as_ref())?; Ok(JobRunnerOptions { 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/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/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/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index ebe3678eb9..78b6aafb96 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -181,7 +181,7 @@ async fn try_resume_closed_agent( receiver_thread_id: ThreadId, child_depth: i32, ) -> Result<(), FunctionCallError> { - let config = build_agent_resume_config(turn.as_ref(), child_depth)?; + let config = build_agent_resume_config(turn.as_ref())?; Box::pin(session.services.agent_control.resume_agent_from_rollout( config, receiver_thread_id, diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 56707fb0ca..108ddd336a 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -116,7 +116,6 @@ async fn handle_spawn_agent( ) .await?; apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; - apply_spawn_agent_overrides(&mut config, child_depth); let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata( config, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index f1e7bcd429..8a4edbff35 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -8,7 +8,6 @@ use crate::session::turn_context::TurnContext; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; -use codex_features::Feature; use codex_models_manager::manager::RefreshStrategy; use codex_protocol::AgentPath; use codex_protocol::ThreadId; @@ -211,12 +210,8 @@ pub(crate) fn build_agent_spawn_config( Ok(config) } -pub(crate) fn build_agent_resume_config( - turn: &TurnContext, - child_depth: i32, -) -> Result { +pub(crate) fn build_agent_resume_config(turn: &TurnContext) -> Result { let mut config = build_agent_shared_config(turn)?; - apply_spawn_agent_overrides(&mut config, child_depth); // For resume, keep base instructions sourced from rollout/session metadata. config.base_instructions = None; Ok(config) @@ -280,13 +275,6 @@ pub(crate) fn apply_spawn_agent_runtime_overrides( Ok(()) } -pub(crate) fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) { - if child_depth >= config.agent_max_depth && !config.features.enabled(Feature::MultiAgentV2) { - let _ = config.features.disable(Feature::SpawnCsv); - let _ = config.features.disable(Feature::Collab); - } -} - pub(crate) async fn apply_requested_spawn_agent_model_overrides( session: &Session, turn: &TurnContext, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e3faf0742e..d4ce84ad55 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -134,6 +134,11 @@ model_reasoning_effort = "minimal" role_name } +fn set_turn_config(turn: &mut TurnContext, config: crate::config::Config) { + turn.multi_agent_version = config.multi_agent_version_from_features(); + turn.config = Arc::new(config); +} + fn expect_text_output(output: T) -> (String, Option) where T: ToolOutput, @@ -382,6 +387,7 @@ async fn multi_agent_v2_spawn_fork_turns_all_rejects_agent_type_override() { .expect("test config should allow feature update"); let turn = TurnContext { config: Arc::new(config), + multi_agent_version: codex_protocol::protocol::MultiAgentVersion::V2, ..turn }; @@ -424,7 +430,7 @@ async fn multi_agent_v2_spawn_defaults_to_full_fork_and_rejects_child_model_over .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let err = SpawnAgentHandlerV2::default() .handle(invocation( @@ -891,7 +897,7 @@ async fn multi_agent_v2_full_history_fork_accepts_explicit_service_tier() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let manager = thread_manager(); let root = manager .start_thread((*turn.config).clone()) @@ -959,6 +965,7 @@ async fn multi_agent_v2_spawn_partial_fork_turns_allows_agent_type_override() { .expect("test config should allow feature update"); let turn = TurnContext { config: Arc::new(config), + multi_agent_version: codex_protocol::protocol::MultiAgentVersion::V2, ..turn }; @@ -1040,7 +1047,7 @@ async fn multi_agent_v2_spawn_requires_task_name() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let invocation = invocation( Arc::new(session), @@ -1074,7 +1081,7 @@ async fn multi_agent_v2_spawn_rejects_legacy_items_field() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let invocation = invocation( Arc::new(session), @@ -1134,7 +1141,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1231,7 +1238,7 @@ async fn multi_agent_v2_spawn_rejects_legacy_fork_context() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let err = SpawnAgentHandlerV2::default() .handle(invocation( @@ -1271,7 +1278,7 @@ async fn multi_agent_v2_spawn_rejects_invalid_fork_turns_string() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let err = SpawnAgentHandlerV2::default() .handle(invocation( @@ -1311,7 +1318,7 @@ async fn multi_agent_v2_spawn_rejects_zero_fork_turns() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let err = SpawnAgentHandlerV2::default() .handle(invocation( @@ -1340,18 +1347,18 @@ async fn multi_agent_v2_spawn_rejects_zero_fork_turns() { async fn multi_agent_v2_send_message_accepts_root_target_from_child() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + set_turn_config(&mut turn, config); let root = manager .start_thread((*turn.config).clone()) .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); session.conversation_id = root.thread_id; - let mut config = (*turn.config).clone(); - config - .features - .enable(Feature::MultiAgentV2) - .expect("test config should allow feature update"); - turn.config = Arc::new(config); let child_path = AgentPath::try_from("/root/worker").expect("agent path"); let child_thread_id = session @@ -1416,18 +1423,18 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() { async fn multi_agent_v2_followup_task_rejects_root_target_from_child() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + set_turn_config(&mut turn, config); let root = manager .start_thread((*turn.config).clone()) .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); session.conversation_id = root.thread_id; - let mut config = (*turn.config).clone(); - config - .features - .enable(Feature::MultiAgentV2) - .expect("test config should allow feature update"); - turn.config = Arc::new(config); let child_path = AgentPath::try_from("/root/worker").expect("agent path"); let child_thread_id = session @@ -1507,7 +1514,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa session.conversation_id = root.thread_id; let mut config = (*turn.config).clone(); let _ = config.features.enable(Feature::MultiAgentV2); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1593,15 +1600,15 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); + let mut config = (*turn.config).clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + set_turn_config(&mut turn, config.clone()); let root = manager .start_thread((*turn.config).clone()) .await .expect("root thread should start"); session.services.agent_control = manager.agent_control(); session.conversation_id = root.thread_id; - let mut config = (*turn.config).clone(); - let _ = config.features.enable(Feature::MultiAgentV2); - turn.config = Arc::new(config.clone()); let researcher_path = AgentPath::from_string("/root/researcher".to_string()).expect("path"); let worker_path = AgentPath::from_string("/root/researcher/worker".to_string()).expect("path"); @@ -1688,7 +1695,7 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() { session.conversation_id = root.thread_id; let mut config = (*turn.config).clone(); let _ = config.features.enable(Feature::MultiAgentV2); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1752,7 +1759,7 @@ async fn multi_agent_v2_send_message_rejects_legacy_items_field() { session.conversation_id = root.thread_id; let mut config = turn.config.as_ref().clone(); let _ = config.features.enable(Feature::MultiAgentV2); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1808,7 +1815,7 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() { session.conversation_id = root.thread_id; let mut config = turn.config.as_ref().clone(); let _ = config.features.enable(Feature::MultiAgentV2); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1873,15 +1880,18 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() { async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); + let mut config = turn.config.as_ref().clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + set_turn_config(&mut turn, config); let root = manager .start_thread((*turn.config).clone()) .await .expect("root thread should start"); + // Production spawn_agent calls happen after the parent turn has resolved + // and stored its runtime; mirror that before using the synthetic handler. + root.thread.codex.session.new_default_turn().await; session.services.agent_control = manager.agent_control(); session.conversation_id = root.thread_id; - let mut config = turn.config.as_ref().clone(); - let _ = config.features.enable(Feature::MultiAgentV2); - turn.config = Arc::new(config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -2016,7 +2026,7 @@ async fn multi_agent_v2_followup_task_rejects_legacy_items_field() { session.conversation_id = root.thread_id; let mut config = turn.config.as_ref().clone(); let _ = config.features.enable(Feature::MultiAgentV2); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -2069,7 +2079,7 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { session.conversation_id = root.thread_id; let mut config = turn.config.as_ref().clone(); let _ = config.features.enable(Feature::MultiAgentV2); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -2149,7 +2159,7 @@ async fn multi_agent_v2_spawn_omits_agent_id_when_named() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let output = SpawnAgentHandlerV2::default() .handle(invocation( @@ -2188,7 +2198,7 @@ async fn multi_agent_v2_spawn_surfaces_task_name_validation_errors() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let invocation = invocation( Arc::new(session), @@ -2398,7 +2408,7 @@ async fn multi_agent_v2_spawn_agent_ignores_configured_max_depth() { .expect("root thread should start"); session.services.agent_control = manager.agent_control(); session.conversation_id = root.thread_id; - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let parent_path = AgentPath::try_from("/root/parent").expect("agent path"); turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: root.thread_id, @@ -2871,7 +2881,7 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -2956,7 +2966,7 @@ async fn multi_agent_v2_wait_agent_rejects_timeout_below_configured_min() { config.multi_agent_v2.min_wait_timeout_ms = 50; config.multi_agent_v2.max_wait_timeout_ms = 1_000; config.multi_agent_v2.default_wait_timeout_ms = 50; - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let Err(err) = WaitAgentHandlerV2::default() .handle(invocation( @@ -2986,7 +2996,7 @@ async fn multi_agent_v2_wait_agent_accepts_explicit_timeout_at_configured_min() config.multi_agent_v2.min_wait_timeout_ms = 1; config.multi_agent_v2.max_wait_timeout_ms = 1_000; config.multi_agent_v2.default_wait_timeout_ms = 50; - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let output = WaitAgentHandlerV2::default() .handle(invocation( @@ -3021,7 +3031,7 @@ async fn multi_agent_v2_wait_agent_uses_configured_default_timeout() { config.multi_agent_v2.min_wait_timeout_ms = 1; config.multi_agent_v2.max_wait_timeout_ms = 1_000; config.multi_agent_v2.default_wait_timeout_ms = 50; - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3076,7 +3086,7 @@ async fn multi_agent_v2_wait_agent_allows_zero_configured_timeout() { config.multi_agent_v2.min_wait_timeout_ms = 0; config.multi_agent_v2.max_wait_timeout_ms = 0; config.multi_agent_v2.default_wait_timeout_ms = 0; - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3116,7 +3126,7 @@ async fn multi_agent_v2_wait_agent_rejects_timeout_above_configured_max() { config.multi_agent_v2.min_wait_timeout_ms = 1; config.multi_agent_v2.max_wait_timeout_ms = 50; config.multi_agent_v2.default_wait_timeout_ms = 1; - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let Err(err) = WaitAgentHandlerV2::default() .handle(invocation( @@ -3146,7 +3156,7 @@ async fn multi_agent_v2_wait_agent_accepts_explicit_timeout_at_configured_max() config.multi_agent_v2.min_wait_timeout_ms = 1; config.multi_agent_v2.max_wait_timeout_ms = 1; config.multi_agent_v2.default_wait_timeout_ms = 1; - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let output = WaitAgentHandlerV2::default() .handle(invocation( @@ -3354,7 +3364,7 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3448,7 +3458,7 @@ async fn multi_agent_v2_wait_agent_returns_for_already_queued_mail() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3529,7 +3539,7 @@ async fn multi_agent_v2_wait_agent_wakes_on_any_mailbox_notification() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3620,7 +3630,7 @@ async fn multi_agent_v2_wait_agent_does_not_return_completed_content() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3709,7 +3719,7 @@ async fn multi_agent_v2_close_agent_accepts_task_name_target() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3757,7 +3767,7 @@ async fn multi_agent_v2_close_agent_accepts_task_name_target() { async fn multi_agent_v2_close_agent_reaps_stale_task_name_target() { let (mut session, mut turn) = make_session_and_context().await; let mut config = (*turn.config).clone(); - config.agent_max_threads = Some(1); + config.multi_agent_v2.max_concurrent_threads_per_session = 2; config .features .enable(Feature::MultiAgentV2) @@ -3782,7 +3792,7 @@ async fn multi_agent_v2_close_agent_reaps_stale_task_name_target() { .expect("root thread should start"); session.services.agent_control = manager.agent_control(); session.conversation_id = root.thread_id; - turn.config = Arc::new(config.clone()); + set_turn_config(&mut turn, config.clone()); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3888,7 +3898,7 @@ async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config); + set_turn_config(&mut turn, config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -4283,7 +4293,7 @@ async fn build_agent_resume_config_clears_base_instructions() { .set(AskForApproval::OnRequest) .expect("approval policy set"); - let config = build_agent_resume_config(&turn, /*child_depth*/ 0).expect("resume config"); + let config = build_agent_resume_config(&turn).expect("resume config"); let mut expected = (*turn.config).clone(); expected.base_instructions = None; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index f767f88d78..ed85d329bd 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -108,7 +108,6 @@ async fn handle_spawn_agent( ) .await?; apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; - apply_spawn_agent_overrides(&mut config, child_depth); let spawn_source = thread_spawn_source( session.conversation_id, diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 70a31fe0e0..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,9 +57,16 @@ impl ToolExecutor for RequestPermissionsHandler { } }; - #[allow(deprecated)] + 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(), + )); + }; 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)?; @@ -61,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/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index c134eae079..51032b27c5 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; use codex_app_server_protocol::AppInfo; use codex_config::types::ToolSuggestDisabledTool; +use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; @@ -273,6 +274,10 @@ async fn verify_request_plugin_install_completed( verified_connector_install_completed(connector.id.as_str(), &accessible_connectors) }), DiscoverableTool::Plugin(plugin) => { + if is_remote_plugin_install_suggestion(&plugin.id) { + return true; + } + session.reload_user_config_layer().await; let config = session.get_config().await; let completed = verified_plugin_install_completed( @@ -293,6 +298,12 @@ async fn verify_request_plugin_install_completed( } } +fn is_remote_plugin_install_suggestion(plugin_id: &str) -> bool { + plugin_id + .rsplit_once('@') + .is_some_and(|(_, marketplace_name)| marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME) +} + #[expect( clippy::await_holding_invalid_type, reason = "connector cache refresh reads through the session-owned manager guard" diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs b/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs index 1a8caf0dce..830e9d408e 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs @@ -57,6 +57,17 @@ async fn verified_plugin_install_completed_requires_installed_plugin() { )); } +#[test] +fn remote_plugin_install_suggestions_skip_core_installed_verification() { + assert!(is_remote_plugin_install_suggestion( + "snowflake@openai-curated-remote" + )); + assert!(!is_remote_plugin_install_suggestion( + "snowflake@openai-curated" + )); + assert!(!is_remote_plugin_install_suggestion("Plugin_123")); +} + #[test] fn request_plugin_install_response_persists_only_decline_always_mode() { assert!(request_plugin_install_response_requests_persistent_disable( 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 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/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index c26fa107c7..ba69f0d039 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -173,6 +173,7 @@ impl ToolExecutor 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, diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index b237f44c40..ae1090c5b4 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -13,10 +13,12 @@ use crate::tools::sandboxing::ToolError; #[cfg(target_os = "macos")] use codex_network_proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; use codex_network_proxy::CUSTOM_CA_ENV_KEYS; +use codex_network_proxy::MITM_CA_ENV_ACTIVE_ENV_KEY; use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; use codex_network_proxy::PROXY_ENV_KEYS; #[cfg(target_os = "macos")] use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; +use codex_network_proxy::SSL_CERT_DIR_ENV_KEY; use codex_network_proxy::is_managed_mitm_ca_trust_bundle_path; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; @@ -258,10 +260,17 @@ fn build_proxy_env_exports() -> (String, String) { let (captures, restores) = build_override_exports_for_keys("__CODEX_SNAPSHOT_PROXY_OVERRIDE", &keys); let key = PROXY_ACTIVE_ENV_KEY; + let (ssl_cert_dir_captures, ssl_cert_dir_restores) = build_override_exports_for_keys( + "__CODEX_SNAPSHOT_MITM_CA_OVERRIDE", + &[SSL_CERT_DIR_ENV_KEY], + ); + let mitm_ca_key = MITM_CA_ENV_ACTIVE_ENV_KEY; let proxy_blocks = ( - format!("{captures}\n__CODEX_SNAPSHOT_PROXY_ENV_SET=\"${{{key}+x}}\""), format!( - "if [ -n \"$__CODEX_SNAPSHOT_PROXY_ENV_SET\" ] || [ -n \"${{{key}+x}}\" ]; then\n{restores}\nfi" + "{captures}\n__CODEX_SNAPSHOT_PROXY_ENV_SET=\"${{{key}+x}}\"\n{ssl_cert_dir_captures}\n__CODEX_SNAPSHOT_MITM_CA_ENV_SET=\"${{{mitm_ca_key}+x}}\"" + ), + format!( + "if [ -n \"$__CODEX_SNAPSHOT_PROXY_ENV_SET\" ] || [ -n \"${{{key}+x}}\" ]; then\n{restores}\nfi\nif [ -n \"$__CODEX_SNAPSHOT_MITM_CA_ENV_SET\" ] || [ -n \"${{{mitm_ca_key}+x}}\" ]; then\n{ssl_cert_dir_restores}\nfi" ), ); let git_blocks = build_codex_proxy_git_ssh_command_exports(); diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 369f301cd7..a0316d13f4 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -11,6 +11,7 @@ use codex_network_proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; use codex_network_proxy::CUSTOM_CA_ENV_KEYS; use codex_network_proxy::ConfigReloader; use codex_network_proxy::ConfigState; +use codex_network_proxy::MITM_CA_ENV_ACTIVE_ENV_KEY; use codex_network_proxy::NetworkProxy; use codex_network_proxy::NetworkProxyConfig; use codex_network_proxy::NetworkProxyConstraints; @@ -19,6 +20,7 @@ use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; use codex_network_proxy::PROXY_ENV_KEYS; #[cfg(target_os = "macos")] use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; +use codex_network_proxy::SSL_CERT_DIR_ENV_KEY; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_sandboxing::SandboxManager; @@ -576,6 +578,87 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_proxy_env_from_process_env() { ); } +#[test] +fn maybe_wrap_shell_lc_with_snapshot_clears_snapshot_ssl_cert_dir() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + format!("# Snapshot file\nexport {SSL_CERT_DIR_ENV_KEY}='/tmp/snapshot-certs'\n"), + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path.abs(), + dir.path().abs(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + format!( + "if [ \"${{{SSL_CERT_DIR_ENV_KEY}+x}}\" = x ]; then printf 'set'; else printf 'unset'; fi" + ), + ]; + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + &dir.path().abs(), + &HashMap::new(), + &HashMap::new(), + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env(MITM_CA_ENV_ACTIVE_ENV_KEY, "1") + .env_remove(SSL_CERT_DIR_ENV_KEY) + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "unset"); +} + +#[test] +fn maybe_wrap_shell_lc_with_snapshot_preserves_snapshot_ssl_cert_dir_without_mitm() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + format!("# Snapshot file\nexport {SSL_CERT_DIR_ENV_KEY}='/tmp/snapshot-certs'\n"), + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path.abs(), + dir.path().abs(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + format!("printf '%s' \"${{{SSL_CERT_DIR_ENV_KEY}}}\""), + ]; + let rewritten = maybe_wrap_shell_lc_with_snapshot( + &command, + &session_shell, + &dir.path().abs(), + &HashMap::new(), + &HashMap::new(), + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env(PROXY_ACTIVE_ENV_KEY, "1") + .env_remove(SSL_CERT_DIR_ENV_KEY) + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "/tmp/snapshot-certs" + ); +} + #[cfg(target_os = "macos")] #[test] fn maybe_wrap_shell_lc_with_snapshot_refreshes_codex_proxy_git_ssh_command() { diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 56d3714074..48111fb578 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -1,3 +1,5 @@ +use crate::agent::exceeds_thread_spawn_depth_limit; +use crate::agent::next_thread_spawn_depth; use crate::session::turn_context::TurnContext; use crate::tools::code_mode::execute_spec::create_code_mode_tool; use crate::tools::context::ToolInvocation; @@ -61,6 +63,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ToolMode; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_tools::DiscoverableTool; @@ -207,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) @@ -264,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) { @@ -288,11 +284,18 @@ fn namespace_tools_enabled(turn_context: &TurnContext) -> bool { } fn multi_agent_v2_enabled(turn_context: &TurnContext) -> bool { - turn_context.features.get().enabled(Feature::MultiAgentV2) + turn_context.multi_agent_version == MultiAgentVersion::V2 } fn collab_tools_enabled(turn_context: &TurnContext) -> bool { - multi_agent_v2_enabled(turn_context) || turn_context.features.get().enabled(Feature::Collab) + match turn_context.multi_agent_version { + MultiAgentVersion::Disabled => false, + MultiAgentVersion::V1 => !exceeds_thread_spawn_depth_limit( + next_thread_spawn_depth(&turn_context.session_source), + turn_context.config.agent_max_depth, + ), + MultiAgentVersion::V2 => true, + } } fn goal_tools_enabled(turn_context: &TurnContext) -> bool { @@ -304,7 +307,7 @@ fn goal_tools_enabled(turn_context: &TurnContext) -> bool { } fn agent_jobs_tools_enabled(turn_context: &TurnContext) -> bool { - turn_context.features.get().enabled(Feature::SpawnCsv) + turn_context.features.get().enabled(Feature::SpawnCsv) && collab_tools_enabled(turn_context) } fn agent_jobs_worker_tools_enabled(turn_context: &TurnContext) -> bool { diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index b75e6d2ed2..02a7a21f63 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -215,6 +215,7 @@ fn set_feature(turn: &mut TurnContext, feature: Feature, enabled: bool) { .disable(feature) .expect("test feature should be disableable in config"); } + turn.multi_agent_version = config.multi_agent_version_from_features(); turn.config = Arc::new(config); turn.tool_mode = turn.model_info.tool_mode.unwrap_or_else(|| { if turn.config.features.enabled(Feature::CodeModeOnly) { @@ -1166,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") @@ -1234,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/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] 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/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index b3574cf5e2..570f97008f 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -233,6 +233,7 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod supports_search_tool: false, auto_review_model_override: Some(review_model.to_string()), tool_mode: None, + multi_agent_version: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 48ba697aa0..c954a954fb 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -2580,6 +2580,7 @@ async fn token_count_includes_rate_limits_snapshot() { "resets_at": 1704074400 }, "credits": null, + "individual_limit": null, "plan_type": null, "rate_limit_reached_type": null } @@ -2655,6 +2656,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "resets_at": null }, "credits": null, + "individual_limit": null, "plan_type": null, "rate_limit_reached_type": null }); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index d9e94204f2..41db88f1ad 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1350,6 +1350,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { "resets_at": null }, "credits": null, + "individual_limit": null, "plan_type": null, "rate_limit_reached_type": null } 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/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index b6f2ea3c7d..c17121b521 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -5,12 +5,14 @@ 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; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::Op; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; @@ -31,6 +33,10 @@ use tokio::time::Duration; use tokio::time::Instant; use tokio::time::sleep; +const CHILD_MODEL: &str = "test-multi-agent-child"; +const ROOT_MODEL: &str = "test-multi-agent-root"; +const ROOT_PROMPT: &str = "spawn a child"; + fn remote_model(slug: &str) -> ModelInfo { ModelInfo { visibility: ModelVisibility::List, @@ -160,14 +166,142 @@ 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(), ] ); Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_multi_agent_selector_overrides_feature_flags() -> Result<()> { + skip_if_no_network!(Ok(())); + + let mut v2_model = remote_model("test-multi-agent-v2"); + v2_model.multi_agent_version = Some(MultiAgentVersion::V2); + let v2_body = response_body_for_remote_model(v2_model, |config| { + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config + .features + .disable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }) + .await?; + assert!(tool_names(&v2_body).contains(&"send_message".to_string())); + + let mut disabled_model = remote_model("test-multi-agent-disabled"); + disabled_model.multi_agent_version = Some(MultiAgentVersion::Disabled); + let disabled_body = response_body_for_remote_model(disabled_model, |config| { + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }) + .await?; + let disabled_tools = tool_names(&disabled_body); + assert!(disabled_tools.iter().all(|name| !matches!( + name.as_str(), + "multi_agent_v1" | "spawn_agent" | "send_message" | "wait_agent" | "list_agents" + ))); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_multi_agent_selector_uses_model_selected_before_first_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = wiremock::MockServer::start().await; + let mut initial_model = remote_model(ROOT_MODEL); + initial_model.multi_agent_version = Some(MultiAgentVersion::V1); + let mut selected_model = remote_model(CHILD_MODEL); + selected_model.multi_agent_version = Some(MultiAgentVersion::V2); + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![initial_model, selected_model], + }, + ) + .await; + let response_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.model = Some(ROOT_MODEL.to_string()); + }); + let test = builder.build(&server).await?; + assert_eq!( + ( + models_mock.requests().len(), + test.codex.multi_agent_version(), + ), + (1, None) + ); + + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + model: Some(CHILD_MODEL.to_string()), + ..Default::default() + }, + ) + .await?; + assert_eq!(test.codex.multi_agent_version(), None); + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: ROOT_PROMPT.into(), + text_elements: Vec::new(), + }], + environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert_eq!( + ( + models_mock.requests().len(), + test.codex.multi_agent_version(), + tool_names( + &response_mock + .last_request() + .expect("expected response request") + .body_json(), + ) + .contains(&"send_message".to_string()), + ), + (1, Some(MultiAgentVersion::V2), true) + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index f7d2bf578c..b76a3522ec 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -114,6 +114,7 @@ fn test_model_info( supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), @@ -933,6 +934,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 5244a178a4..34fc98b59f 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -372,5 +372,6 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, } } diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 4219576d4f..231a6d826f 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -594,6 +594,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, }; let _models_mock = mount_models_once( @@ -706,6 +707,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, }; let _models_mock = mount_models_once( diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 84a2e18824..5302274153 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -75,6 +75,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }; @@ -124,6 +125,7 @@ async fn write_rollout_with_meta_only(dir: &Path, thread_id: ThreadId) -> io::Re base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }; diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index d7d7c7e8f5..3390e38ef6 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -18,6 +18,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::stdio_server_bin; +use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_mcp_server; @@ -101,7 +102,7 @@ fn write_plugin_app_plugin(home: &TempDir) { async fn build_analytics_plugin_test_codex( server: &MockServer, codex_home: Arc, -) -> Result> { +) -> Result { let chatgpt_base_url = server.uri(); let mut builder = test_codex() .with_home(codex_home) @@ -113,15 +114,14 @@ async fn build_analytics_plugin_test_codex( Ok(builder .build(server) .await - .expect("create new conversation") - .codex) + .expect("create new conversation")) } async fn build_apps_enabled_plugin_test_codex( server: &MockServer, codex_home: Arc, chatgpt_base_url: String, -) -> Result> { +) -> Result { let mut builder = test_codex() .with_home(codex_home) .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) @@ -135,8 +135,7 @@ async fn build_apps_enabled_plugin_test_codex( Ok(builder .build(server) .await - .expect("create new conversation") - .codex) + .expect("create new conversation")) } fn tool_names(body: &serde_json::Value) -> Vec { @@ -171,12 +170,13 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() let codex_home = Arc::new(TempDir::new()?); write_plugin_skill_plugin(codex_home.as_ref()); write_plugin_app_plugin(codex_home.as_ref()); - let codex = build_apps_enabled_plugin_test_codex( + let test_codex = build_apps_enabled_plugin_test_codex( &server, Arc::clone(&codex_home), apps_server.chatgpt_base_url, ) .await?; + let codex = Arc::clone(&test_codex.codex); codex .submit(Op::UserInput { @@ -253,9 +253,10 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { write_plugin_mcp_plugin(codex_home.as_ref(), &rmcp_test_server_bin); write_plugin_app_plugin(codex_home.as_ref()); - let codex = + let test_codex = build_apps_enabled_plugin_test_codex(&server, codex_home, apps_server.chatgpt_base_url) .await?; + let codex = Arc::clone(&test_codex.codex); wait_for_mcp_server(&codex, "sample").await?; codex @@ -339,7 +340,8 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { let codex_home = Arc::new(TempDir::new()?); write_plugin_skill_plugin(codex_home.as_ref()); - let codex = build_analytics_plugin_test_codex(&server, codex_home).await?; + let test_codex = build_analytics_plugin_test_codex(&server, codex_home).await?; + let codex = Arc::clone(&test_codex.codex); codex .submit(Op::UserInput { 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(())); diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 00222a3a3a..fd6e01c075 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -479,6 +479,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), @@ -730,6 +731,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), @@ -1215,6 +1217,7 @@ fn test_remote_model_with_policy( supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, priority, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 506fa46ccd..54b046e66c 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -39,6 +39,7 @@ fn resume_history( model: previous_model.to_string(), personality: None, collaboration_mode: None, + multi_agent_version: None, realtime_active: None, effort: config.model_reasoning_effort, summary: config diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index c3d598c7ba..36ea7d4b2c 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1351,6 +1351,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, }], }, ) diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 787ba10501..c1f0d522c7 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -62,6 +62,7 @@ fn test_model_info( supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers, diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 768220376c..94deeb48a0 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -222,6 +222,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }; 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/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 0c6cdf6d8b..2e34475387 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1357,6 +1357,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), 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/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/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 71007e3817..98bc395a2f 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -2,6 +2,7 @@ use std::future::Future; use std::sync::Arc; use codex_protocol::items::TurnItem; +use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::TokenUsageInfo; use codex_tools::ToolCall; @@ -12,6 +13,7 @@ use crate::ExtensionData; mod prompt; mod thread_lifecycle; mod tool_lifecycle; +mod turn_input; mod turn_lifecycle; pub use prompt::PromptFragment; @@ -25,6 +27,8 @@ pub use tool_lifecycle::ToolCallSource; pub use tool_lifecycle::ToolFinishInput; pub use tool_lifecycle::ToolLifecycleFuture; pub use tool_lifecycle::ToolStartInput; +pub use turn_input::TurnInputContext; +pub use turn_input::TurnInputEnvironment; pub use turn_lifecycle::TurnAbortInput; pub use turn_lifecycle::TurnErrorInput; pub use turn_lifecycle::TurnStartInput; @@ -85,6 +89,25 @@ pub trait TurnLifecycleContributor: Send + Sync { async fn on_turn_error(&self, _input: TurnErrorInput<'_>) {} } +/// WARNING: DO NOT USE YET +/// Extension contribution that can add turn-local model input. +/// +/// Implementations should resolve only the model-visible input they own and +/// must preserve authority boundaries for external resources. Expensive or +/// host-specific dependencies belong on the extension value installed by the +/// host, not in this input. +#[async_trait::async_trait] +pub trait TurnInputContributor: Send + Sync { + /// Returns additional model input items for one submitted turn. + async fn contribute( + &self, + input: TurnInputContext, + session_store: &ExtensionData, + thread_store: &ExtensionData, + turn_store: &ExtensionData, + ) -> Vec; +} + /// Contributor for host-owned configuration changes. /// /// Implementations should treat the supplied values as immutable before/after diff --git a/codex-rs/ext/extension-api/src/contributors/turn_input.rs b/codex-rs/ext/extension-api/src/contributors/turn_input.rs new file mode 100644 index 0000000000..9e4723440d --- /dev/null +++ b/codex-rs/ext/extension-api/src/contributors/turn_input.rs @@ -0,0 +1,25 @@ +use std::path::PathBuf; + +use codex_protocol::user_input::UserInput; + +/// Host-owned turn environment summary visible to turn-input contributors. +#[derive(Debug, Clone)] +pub struct TurnInputEnvironment { + /// Stable host environment id used to route executor-scoped capabilities. + pub environment_id: String, + /// Effective working directory for this turn in the environment. + pub cwd: PathBuf, + /// Whether this is the primary environment for the turn. + pub is_primary: bool, +} + +/// Turn facts supplied before the host records turn-local model input items. +#[derive(Debug, Clone)] +pub struct TurnInputContext { + /// Stable host-owned turn identifier. + pub turn_id: String, + /// User input submitted for this turn. + pub user_input: Vec, + /// Resolved turn environments, in host priority order. + pub environments: Vec, +} diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 2f00a8dd98..870e331fbb 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -10,10 +10,10 @@ pub use capabilities::NoopExtensionEventSink; pub use capabilities::NoopResponseItemInjector; pub use capabilities::ResponseItemInjectionFuture; pub use capabilities::ResponseItemInjector; +pub use codex_protocol::models::ResponseItem; 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; @@ -47,6 +47,9 @@ pub use contributors::ToolLifecycleFuture; pub use contributors::ToolStartInput; pub use contributors::TurnAbortInput; pub use contributors::TurnErrorInput; +pub use contributors::TurnInputContext; +pub use contributors::TurnInputContributor; +pub use contributors::TurnInputEnvironment; pub use contributors::TurnItemContributor; pub use contributors::TurnLifecycleContributor; pub use contributors::TurnStartInput; diff --git a/codex-rs/ext/extension-api/src/registry.rs b/codex-rs/ext/extension-api/src/registry.rs index a359726257..0849387110 100644 --- a/codex-rs/ext/extension-api/src/registry.rs +++ b/codex-rs/ext/extension-api/src/registry.rs @@ -12,6 +12,7 @@ use crate::ThreadLifecycleContributor; use crate::TokenUsageContributor; use crate::ToolContributor; use crate::ToolLifecycleContributor; +use crate::TurnInputContributor; use crate::TurnItemContributor; use crate::TurnLifecycleContributor; @@ -23,6 +24,7 @@ pub struct ExtensionRegistryBuilder { config_contributors: Vec>>, token_usage_contributors: Vec>, context_contributors: Vec>, + turn_input_contributors: Vec>, tool_contributors: Vec>, tool_lifecycle_contributors: Vec>, turn_item_contributors: Vec>, @@ -39,6 +41,7 @@ impl Default for ExtensionRegistryBuilder { token_usage_contributors: Vec::new(), approval_review_contributors: Vec::new(), context_contributors: Vec::new(), + turn_input_contributors: Vec::new(), tool_contributors: Vec::new(), tool_lifecycle_contributors: Vec::new(), turn_item_contributors: Vec::new(), @@ -98,6 +101,11 @@ impl ExtensionRegistryBuilder { self.context_contributors.push(contributor); } + /// Registers one turn-input contributor. + pub fn turn_input_contributor(&mut self, contributor: Arc) { + self.turn_input_contributors.push(contributor); + } + /// Registers one native tool contributor. pub fn tool_contributor(&mut self, contributor: Arc) { self.tool_contributors.push(contributor); @@ -123,6 +131,7 @@ impl ExtensionRegistryBuilder { token_usage_contributors: self.token_usage_contributors, approval_review_contributors: self.approval_review_contributors, context_contributors: self.context_contributors, + turn_input_contributors: self.turn_input_contributors, tool_contributors: self.tool_contributors, tool_lifecycle_contributors: self.tool_lifecycle_contributors, turn_item_contributors: self.turn_item_contributors, @@ -138,6 +147,7 @@ pub struct ExtensionRegistry { config_contributors: Vec>>, token_usage_contributors: Vec>, context_contributors: Vec>, + turn_input_contributors: Vec>, tool_contributors: Vec>, tool_lifecycle_contributors: Vec>, turn_item_contributors: Vec>, @@ -195,6 +205,11 @@ impl ExtensionRegistry { &self.context_contributors } + /// Returns the registered turn-input contributors. + pub fn turn_input_contributors(&self) -> &[Arc] { + &self.turn_input_contributors + } + /// Returns the registered native tool contributors. pub fn tool_contributors(&self) -> &[Arc] { &self.tool_contributors 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..e4e8e13135 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; @@ -75,15 +77,24 @@ impl ToolExecutor for ImageGenerationTool { imagegen_tool_spec() } - /// Keeps this model-facing tool out of the nested code-mode tool surface. + /// Exposes image generation directly and through the nested code-mode tool surface. fn exposure(&self) -> ToolExposure { - ToolExposure::DirectModelOnly + ToolExposure::Direct } /// Executes the selected image operation and returns the completed image result. 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/ext/skills/BUILD.bazel b/codex-rs/ext/skills/BUILD.bazel new file mode 100644 index 0000000000..81410ca1f6 --- /dev/null +++ b/codex-rs/ext/skills/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "skills", + crate_name = "codex_skills_extension", +) diff --git a/codex-rs/ext/skills/Cargo.toml b/codex-rs/ext/skills/Cargo.toml new file mode 100644 index 0000000000..55671df6c9 --- /dev/null +++ b/codex-rs/ext/skills/Cargo.toml @@ -0,0 +1,19 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-skills-extension" +version.workspace = true + +[lib] +name = "codex_skills_extension" +path = "src/lib.rs" +test = false +doctest = false + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +codex-core = { workspace = true } +codex-extension-api = { workspace = true } diff --git a/codex-rs/ext/skills/src/catalog.rs b/codex-rs/ext/skills/src/catalog.rs new file mode 100644 index 0000000000..86ae327d45 --- /dev/null +++ b/codex-rs/ext/skills/src/catalog.rs @@ -0,0 +1,86 @@ +/// Source authority that owns a skill package and must be used to read it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum SkillSourceKind { + Host, + Executor, + Remote, +} + +/// Opaque authority identity for list/read routing. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SkillAuthority { + pub kind: SkillSourceKind, + pub id: String, +} + +impl SkillAuthority { + pub fn new(kind: SkillSourceKind, id: impl Into) -> Self { + Self { + kind, + id: id.into(), + } + } +} + +/// Opaque package id. Callers should not parse local paths out of this value. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SkillPackageId(pub String); + +/// Opaque resource id inside a skill package. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SkillResourceId(pub String); + +/// Metadata shown in the always-visible skills catalog. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillCatalogEntry { + pub id: SkillPackageId, + pub authority: SkillAuthority, + pub name: String, + pub description: String, + pub entrypoint: SkillResourceId, +} + +/// Merged catalog for one turn. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SkillCatalog { + pub entries: Vec, + pub warnings: Vec, +} + +impl SkillCatalog { + pub fn extend(&mut self, other: SkillCatalog) { + // TODO(skills-extension): dedupe by authority-bound id first, then + // apply name precedence/conflict rules for user-facing mention + // resolution. Names are not stable identities. + self.entries.extend(other.entries); + self.warnings.extend(other.warnings); + } +} + +/// Contents returned after resolving a skill resource through its owner. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillReadResult { + pub resource: SkillResourceId, + pub contents: String, +} + +/// Search results for a package whose files are not readable through ordinary +/// executor filesystem access. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SkillSearchResult { + pub matches: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillSearchMatch { + pub resource: SkillResourceId, + pub title: String, + pub snippet: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillProviderError { + pub message: String, +} + +pub type SkillProviderResult = Result; diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs new file mode 100644 index 0000000000..bf91b60de1 --- /dev/null +++ b/codex-rs/ext/skills/src/extension.rs @@ -0,0 +1,140 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use codex_core::config::Config; +use codex_extension_api::ConfigContributor; +use codex_extension_api::ContextContributor; +use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::PromptFragment; +use codex_extension_api::ThreadLifecycleContributor; +use codex_extension_api::ThreadStartInput; +use codex_extension_api::TurnLifecycleContributor; +use codex_extension_api::TurnStartInput; + +use crate::provider::SkillListQuery; +use crate::providers::SkillProviders; +use crate::state::SkillsExtensionConfig; +use crate::state::SkillsTurnState; + +#[derive(Clone, Debug, Default)] +struct SkillsExtension { + providers: SkillProviders, +} + +#[async_trait::async_trait] +impl ThreadLifecycleContributor for SkillsExtension { + async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { + // TODO(skills-extension): this is only the thread-level config snapshot. + // Skills are loaded per turn today because cwd, plugin roots, config + // layers, and the primary environment filesystem can change between + // turns. The real migration needs a turn-preparation hook before model + // input construction, not just thread startup. + input + .thread_store + .insert(SkillsExtensionConfig::from_config(input.config)); + } +} + +impl ConfigContributor for SkillsExtension { + fn on_config_changed( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + _previous_config: &Config, + new_config: &Config, + ) { + // TODO(skills-extension): update any cached/listing state that depends + // on skill config overrides, bundled skills, or include_instructions. + thread_store.insert(SkillsExtensionConfig::from_config(new_config)); + } +} + +impl ContextContributor for SkillsExtension { + fn contribute<'a>( + &'a self, + _session_store: &'a ExtensionData, + thread_store: &'a ExtensionData, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let Some(config) = thread_store.get::() else { + return Vec::new(); + }; + if !config.include_instructions { + return Vec::new(); + } + + // TODO(skills-extension): render the available-skills developer + // block from the merged per-turn SkillCatalog. This should + // preserve the existing bounded metadata budget, root aliasing, + // warning behavior, and telemetry side effects. + // + // TODO(skills-extension): avoid using raw PromptFragment strings + // for final skills context if the extension API grows typed + // contextual fragments. Existing skill blocks are typed so resume + // and history filtering can recognize them reliably. + // + // TODO(skills-extension): ContextContributor currently cannot see + // the turn_store, so it cannot read the per-turn catalog seeded by + // the turn provider path below. This is the main extension-api gap + // to close before skills can move out of codex-core. + Vec::new() + }) + } +} + +#[async_trait::async_trait] +impl TurnLifecycleContributor for SkillsExtension { + async fn on_turn_start(&self, input: TurnStartInput<'_>) { + // TODO(skills-extension): replace this lifecycle callback with a real + // turn-input contributor in codex-extension-api. This placeholder only + // demonstrates where provider aggregation belongs; it cannot resolve + // real skills because this hook does not receive cwd, executor + // selections, effective plugins/materialized plugin skill roots, + // connector slug counts, user input, cancellation, analytics, or a + // response-item output channel. + let query = SkillListQuery::placeholder_for_turn(input.turn_id); + let catalog = self + .providers + .list_for_turn(query) + .await + .unwrap_or_default(); + + input.turn_store.insert(SkillsTurnState { + catalog, + entrypoints_injected: false, + }); + + // TODO(skills-extension): after catalog resolution, collect explicit + // skill mentions from structured UserInput and text mentions. + // + // TODO(skills-extension): inject selected entrypoints as typed + // contextual user fragments, preserving ... history + // recognition and bounded body size limits. + // + // TODO(skills-extension): move explicit $skill mention resolution, + // SKILL.md reads, skill body injection, and MCP dependency prompting + // out of codex-core's turn assembly once that hook exists. + } +} + +/// Installs the skills extension contributor sketch. +/// +/// TODO(skills-extension): pass host capabilities here rather than letting the +/// extension depend on Session. The final extension needs capability objects for +/// loading skill roots, emitting warnings, tracking analytics, prompting for MCP +/// dependency install, refreshing MCP servers, and serving app-server catalog +/// requests. +/// +/// TODO(skills-extension): plugin handling should stay outside the runtime +/// skills model. Plugins are bundle/install units; once installed or refreshed, +/// their skill descriptors/roots should be handed to this extension just like +/// any other host-owned skill source. +pub fn install(registry: &mut ExtensionRegistryBuilder) { + let extension = Arc::new(SkillsExtension::default()); + registry.thread_lifecycle_contributor(extension.clone()); + registry.config_contributor(extension.clone()); + registry.prompt_contributor(extension.clone()); + registry.turn_lifecycle_contributor(extension); +} diff --git a/codex-rs/ext/skills/src/lib.rs b/codex-rs/ext/skills/src/lib.rs new file mode 100644 index 0000000000..6e8a0700a1 --- /dev/null +++ b/codex-rs/ext/skills/src/lib.rs @@ -0,0 +1,7 @@ +pub mod catalog; +mod extension; +pub mod provider; +mod providers; +mod state; + +pub use extension::install; diff --git a/codex-rs/ext/skills/src/provider.rs b/codex-rs/ext/skills/src/provider.rs new file mode 100644 index 0000000000..730adb51ba --- /dev/null +++ b/codex-rs/ext/skills/src/provider.rs @@ -0,0 +1,63 @@ +use std::future::Future; + +use crate::catalog::SkillAuthority; +use crate::catalog::SkillCatalog; +use crate::catalog::SkillPackageId; +use crate::catalog::SkillProviderResult; +use crate::catalog::SkillReadResult; +use crate::catalog::SkillResourceId; +use crate::catalog::SkillSearchResult; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillListQuery { + pub turn_id: String, + pub executor_authorities: Vec, + pub include_host_skills: bool, + pub include_remote_skills: bool, +} + +impl SkillListQuery { + pub(crate) fn placeholder_for_turn(turn_id: &str) -> Self { + Self { + turn_id: turn_id.to_string(), + executor_authorities: Vec::new(), + include_host_skills: true, + include_remote_skills: true, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillReadRequest { + pub authority: SkillAuthority, + pub resource: SkillResourceId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SkillSearchRequest { + pub authority: SkillAuthority, + pub package: SkillPackageId, + pub query: String, +} + +/// Source-specific skill catalog and resource access. +/// +/// Implementations must preserve authority boundaries: a resource listed by a +/// provider must be read or searched through the same provider/authority rather +/// than converted into an ambient local path. +pub trait SkillProvider: Send + Sync { + fn list( + &self, + query: SkillListQuery, + ) -> impl Future> + Send; + + fn read( + &self, + request: SkillReadRequest, + ) -> impl Future> + Send; + + fn search( + &self, + request: SkillSearchRequest, + ) -> impl Future> + Send; +} diff --git a/codex-rs/ext/skills/src/providers/executor.rs b/codex-rs/ext/skills/src/providers/executor.rs new file mode 100644 index 0000000000..f9f399eebc --- /dev/null +++ b/codex-rs/ext/skills/src/providers/executor.rs @@ -0,0 +1,56 @@ +use std::future; + +use crate::catalog::SkillCatalog; +use crate::catalog::SkillProviderResult; +use crate::catalog::SkillReadResult; +use crate::catalog::SkillSearchResult; +use crate::provider::SkillListQuery; +use crate::provider::SkillProvider; +use crate::provider::SkillReadRequest; +use crate::provider::SkillSearchRequest; + +#[derive(Clone, Debug, Default)] +pub(crate) struct ExecutorSkillProvider; + +impl SkillProvider for ExecutorSkillProvider { + fn list( + &self, + _query: SkillListQuery, + ) -> impl Future> + Send { + future::ready(Ok(SkillCatalog::default())) + // TODO(skills-extension): list repo/workspace skills from each + // executor authority selected for the turn. + // + // TODO(skills-extension): if the executor exposes filesystem reads, + // preserve the existing SKILL.md discovery semantics. If CCA/no-FS + // applies, query an executor catalog/read capability instead. + // + // TODO(skills-extension): include the executor/environment id in skill + // identity so two executors with the same path/name do not collide. + } + + fn read( + &self, + request: SkillReadRequest, + ) -> impl Future> + Send { + future::ready(Err(crate::catalog::SkillProviderError { + message: format!( + "executor skill resource `{}` is not implemented", + request.resource.0 + ), + })) + // TODO(skills-extension): route reads back to the executor authority + // that listed the resource. Do not mint local paths from remote or + // non-filesystem executor resources. + } + + fn search( + &self, + _request: SkillSearchRequest, + ) -> impl Future> + Send { + future::ready(Ok(SkillSearchResult::default())) + // TODO(skills-extension): support search for executor skills only when + // the executor offers a catalog/search API. For ordinary filesystem + // executors, the model can keep using regular file reads/search tools. + } +} diff --git a/codex-rs/ext/skills/src/providers/host.rs b/codex-rs/ext/skills/src/providers/host.rs new file mode 100644 index 0000000000..fc4317f859 --- /dev/null +++ b/codex-rs/ext/skills/src/providers/host.rs @@ -0,0 +1,65 @@ +use std::future; + +use crate::catalog::SkillCatalog; +use crate::catalog::SkillProviderResult; +use crate::catalog::SkillReadResult; +use crate::catalog::SkillSearchResult; +use crate::provider::SkillListQuery; +use crate::provider::SkillProvider; +use crate::provider::SkillReadRequest; +use crate::provider::SkillSearchRequest; + +#[derive(Clone, Debug, Default)] +pub(crate) struct HostSkillProvider; + +impl SkillProvider for HostSkillProvider { + fn list( + &self, + _query: SkillListQuery, + ) -> impl Future> + Send { + future::ready(Ok(SkillCatalog::default())) + // TODO(skills-extension): list bundled/system/user/plugin-installed + // skills owned by the Codex host. This is the source for skills that + // are not tied to a particular executor authority. + // + // TODO(skills-extension): plugins should be treated as packaging and + // installation only. After a plugin is downloaded, cached, refreshed, + // or installed, its skill roots/descriptors should enter this provider + // and then use the normal skills catalog/read/injection code. + // + // TODO(skills-extension): remote skills that are materialized locally + // by plugin install or explicit download should also hand off here + // rather than remain remote-provider entries at runtime. + // + // TODO(skills-extension): keep current bundled system skill install or + // replace it with embedded host assets so CCA/no-FS hosts do not depend + // on local writable skill cache directories. + } + + fn read( + &self, + request: SkillReadRequest, + ) -> impl Future> + Send { + future::ready(Err(crate::catalog::SkillProviderError { + message: format!( + "host skill resource `{}` is not implemented", + request.resource.0 + ), + })) + // TODO(skills-extension): read host-owned entrypoints and supporting + // resources by opaque id, not by assuming a local filesystem path. + // + // TODO(skills-extension): for plugin-installed skills, route reads + // through the materialized plugin cache/root that produced the catalog + // entry, while keeping the public id opaque and authority-bound. + } + + fn search( + &self, + _request: SkillSearchRequest, + ) -> impl Future> + Send { + future::ready(Ok(SkillSearchResult::default())) + // TODO(skills-extension): decide whether host skills need search, or + // whether direct read by opaque resource id is enough. + } +} diff --git a/codex-rs/ext/skills/src/providers/mod.rs b/codex-rs/ext/skills/src/providers/mod.rs new file mode 100644 index 0000000000..fcf2788480 --- /dev/null +++ b/codex-rs/ext/skills/src/providers/mod.rs @@ -0,0 +1,50 @@ +mod executor; +mod host; +mod remote; + +use crate::catalog::SkillCatalog; +use crate::catalog::SkillProviderResult; +use crate::provider::SkillListQuery; +use crate::provider::SkillProvider; + +use executor::ExecutorSkillProvider; +use host::HostSkillProvider; +use remote::RemoteSkillProvider; + +#[derive(Clone, Debug, Default)] +pub(crate) struct SkillProviders { + host: HostSkillProvider, + executor: ExecutorSkillProvider, + remote: RemoteSkillProvider, +} + +impl SkillProviders { + pub(crate) async fn list_for_turn( + &self, + query: SkillListQuery, + ) -> SkillProviderResult { + let mut catalog = SkillCatalog::default(); + + if query.include_host_skills { + catalog.extend(self.host.list(query.clone()).await?); + } + + if !query.executor_authorities.is_empty() { + catalog.extend(self.executor.list(query.clone()).await?); + } + + if query.include_remote_skills { + catalog.extend(self.remote.list(query).await?); + } + + // TODO(skills-extension): apply final merged-catalog policy here: + // source precedence, duplicate name handling, disabled-skill rules, + // product/session-source filtering, and telemetry for omitted entries. + // + // TODO(skills-extension): treat plugin-installed skills as ordinary + // host catalog entries by this point. Plugin identity may remain useful + // for display, auth, and uninstall flows, but mention resolution and + // entrypoint injection should not special-case plugin packaging. + Ok(catalog) + } +} diff --git a/codex-rs/ext/skills/src/providers/remote.rs b/codex-rs/ext/skills/src/providers/remote.rs new file mode 100644 index 0000000000..22dd3368a5 --- /dev/null +++ b/codex-rs/ext/skills/src/providers/remote.rs @@ -0,0 +1,58 @@ +use std::future; + +use crate::catalog::SkillCatalog; +use crate::catalog::SkillProviderResult; +use crate::catalog::SkillReadResult; +use crate::catalog::SkillSearchResult; +use crate::provider::SkillListQuery; +use crate::provider::SkillProvider; +use crate::provider::SkillReadRequest; +use crate::provider::SkillSearchRequest; + +#[derive(Clone, Debug, Default)] +pub(crate) struct RemoteSkillProvider; + +impl SkillProvider for RemoteSkillProvider { + fn list( + &self, + _query: SkillListQuery, + ) -> impl Future> + Send { + future::ready(Ok(SkillCatalog::default())) + // TODO(skills-extension): list org/account/backend skills from a + // remote catalog only when they are not installed/materialized into the + // host. These skills should use opaque ids and backend authority, not + // paths. + // + // TODO(skills-extension): if a remote skill is downloaded or installed + // as part of a plugin-like bundle, hand it to HostSkillProvider for + // runtime listing/read instead of keeping a separate remote runtime + // path. + // + // TODO(skills-extension): decide how org policy and local enable/disable + // rules combine when the backend supplies a managed skill catalog. + } + + fn read( + &self, + request: SkillReadRequest, + ) -> impl Future> + Send { + future::ready(Err(crate::catalog::SkillProviderError { + message: format!( + "remote skill resource `{}` is not implemented", + request.resource.0 + ), + })) + // TODO(skills-extension): read remote skill entrypoints and supporting + // files through authenticated backend APIs. + } + + fn search( + &self, + _request: SkillSearchRequest, + ) -> impl Future> + Send { + future::ready(Ok(SkillSearchResult::default())) + // TODO(skills-extension): expose model-facing skills/search or resource + // APIs for large remote packages so the model can progressively + // disclose supporting files without filesystem access. + } +} diff --git a/codex-rs/ext/skills/src/state.rs b/codex-rs/ext/skills/src/state.rs new file mode 100644 index 0000000000..d6c355ed34 --- /dev/null +++ b/codex-rs/ext/skills/src/state.rs @@ -0,0 +1,24 @@ +use codex_core::config::Config; + +use crate::catalog::SkillCatalog; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SkillsExtensionConfig { + pub(crate) include_instructions: bool, + pub(crate) bundled_skills_enabled: bool, +} + +impl SkillsExtensionConfig { + pub(crate) fn from_config(config: &Config) -> Self { + Self { + include_instructions: config.include_skill_instructions, + bundled_skills_enabled: config.bundled_skills_enabled(), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct SkillsTurnState { + pub(crate) catalog: SkillCatalog, + pub(crate) entrypoints_injected: bool, +} 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/memories/write/src/guard_tests.rs b/codex-rs/memories/write/src/guard_tests.rs index 139a8773ca..0659aabd19 100644 --- a/codex-rs/memories/write/src/guard_tests.rs +++ b/codex-rs/memories/write/src/guard_tests.rs @@ -11,6 +11,7 @@ fn snapshot( primary: primary_used_percent.map(window), secondary: secondary_used_percent.map(window), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, } diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index 379d173e4e..58137a3f50 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -101,6 +101,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, } } diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index a400038382..fd9ced3568 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -44,4 +44,12 @@ pretty_assertions = { workspace = true } tempfile = { workspace = true } [target.'cfg(target_family = "unix")'.dependencies] +libc = { workspace = true } rama-unix = { version = "=0.3.0-alpha.4" } + +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3" + +[target.'cfg(windows)'.dependencies] +schannel = "0.1" +windows-sys = { version = "0.52", features = ["Win32_Storage_FileSystem"] } diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index 001469aa9a..7c68e81550 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -23,11 +23,27 @@ use rama_tls_rustls::server::TlsAcceptorData; use sha2::Digest as _; use sha2::Sha256; use std::collections::HashMap; +use std::ffi::OsStr; +#[cfg(windows)] +use std::ffi::OsString; use std::fs; use std::fs::File; use std::fs::OpenOptions; +use std::io::Read; use std::io::Write; use std::net::IpAddr; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::os::fd::AsRawFd; +#[cfg(target_os = "macos")] +use std::os::unix::ffi::OsStrExt; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; +#[cfg(windows)] +use std::os::windows::ffi::OsStringExt; +#[cfg(windows)] +use std::os::windows::fs::MetadataExt; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; use std::path::Path; use std::path::PathBuf; use std::time::SystemTime; @@ -101,12 +117,15 @@ const MANAGED_MITM_CA_DIR: &str = "proxy"; const MANAGED_MITM_CA_CERT: &str = "ca.pem"; const MANAGED_MITM_CA_KEY: &str = "ca.key"; const MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX: &str = "ca-bundle"; +const MAX_CUSTOM_CA_BUNDLE_BYTES: u64 = 4 * 1024 * 1024; +const SSL_CERT_FILE_ENV_KEY: &str = "SSL_CERT_FILE"; +pub const SSL_CERT_DIR_ENV_KEY: &str = "SSL_CERT_DIR"; // Best-effort compatibility set for common child toolchains that accept a CA bundle path. // This is intentionally curated rather than pretending to cover every TLS client. pub const CUSTOM_CA_ENV_KEYS: [&str; 10] = [ "CODEX_CA_CERTIFICATE", - "SSL_CERT_FILE", + SSL_CERT_FILE_ENV_KEY, "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "NODE_EXTRA_CA_CERTS", @@ -122,6 +141,7 @@ pub const CUSTOM_CA_ENV_KEYS: [&str; 10] = [ pub(crate) struct ManagedMitmCaTrustBundle { pub(crate) path: PathBuf, pub(crate) startup_env_values: HashMap<&'static str, String>, + pub(crate) startup_cwd: PathBuf, } fn managed_ca_paths() -> Result<(PathBuf, PathBuf)> { @@ -146,8 +166,11 @@ fn managed_ca_trust_bundle_for_cert_path( cert_path: &Path, env: &HashMap<&'static str, String>, ) -> Result { + let startup_cwd = + std::env::current_dir().context("failed to resolve startup cwd for managed MITM CA")?; let startup_env_values = CUSTOM_CA_ENV_KEYS .into_iter() + .chain(std::iter::once(SSL_CERT_DIR_ENV_KEY)) .filter_map(|key| { env.get(key) .filter(|value| !value.is_empty()) @@ -160,13 +183,14 @@ fn managed_ca_trust_bundle_for_cert_path( Ok(ManagedMitmCaTrustBundle { path, startup_env_values, + startup_cwd, }) } fn build_managed_ca_trust_bundle(managed_ca_cert_path: &Path) -> Result { let mut trust_bundle = String::new(); let rustls_native_certs::CertificateResult { certs, errors, .. } = - rustls_native_certs::load_native_certs(); + crate::native_certs::load_platform_native_certs(); if !errors.is_empty() { warn!( native_root_error_count = errors.len(), @@ -184,18 +208,23 @@ fn is_current_generated_trust_bundle_path(path: &Path, managed_ca_cert_path: &Pa let Some(proxy_dir) = managed_ca_cert_path.parent() else { return false; }; - let Some(file_name) = path.file_name().and_then(|file_name| file_name.to_str()) else { - return false; - }; - if path.parent() != Some(proxy_dir) - || !file_name.starts_with(MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX) - || !file_name.ends_with(".pem") - { + if !matches_generated_trust_bundle_path(path, proxy_dir) { return false; } let Ok(trust_bundle) = fs::read(path) else { return false; }; + let expected_hash = format!("{:x}", Sha256::digest(&trust_bundle)); + if path + .file_stem() + .and_then(OsStr::to_str) + .and_then(|file_stem| { + file_stem.strip_prefix(&format!("{MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX}-")) + }) + .is_none_or(|hash| hash != expected_hash) + { + return false; + } let Ok(managed_ca_cert) = fs::read(managed_ca_cert_path) else { return false; }; @@ -220,6 +249,128 @@ fn persist_managed_ca_trust_bundle( let proxy_dir = managed_ca_cert_path .parent() .ok_or_else(|| anyhow!("managed MITM CA cert path is missing a parent"))?; + persist_ca_trust_bundle(proxy_dir, trust_bundle) +} + +pub(crate) fn materialize_ca_trust_bundle_with_custom_ca( + managed_ca_trust_bundle: &ManagedMitmCaTrustBundle, + custom_ca_bundle: &str, +) -> Result { + let proxy_dir = managed_ca_trust_bundle + .path + .parent() + .ok_or_else(|| anyhow!("managed MITM CA trust bundle path is missing a parent"))?; + anyhow::ensure!( + custom_ca_bundle.len() as u64 <= MAX_CUSTOM_CA_BUNDLE_BYTES, + "custom CA bundle exceeds {MAX_CUSTOM_CA_BUNDLE_BYTES} bytes" + ); + + let mut trust_bundle = String::new(); + append_pem_contents(&mut trust_bundle, custom_ca_bundle); + append_pem_file(&mut trust_bundle, &managed_ca_trust_bundle.path)?; + persist_ca_trust_bundle(proxy_dir, &trust_bundle) +} + +pub(crate) fn read_custom_ca_bundle(path: &Path, can_read_path: F) -> Result +where + F: Fn(&Path) -> bool, +{ + anyhow::ensure!( + can_read_path(path), + "CA bundle {} is not readable by child policy", + path.display() + ); + let path = path + .canonicalize() + .with_context(|| format!("failed to resolve CA bundle {}", path.display()))?; + anyhow::ensure!( + can_read_path(&path), + "CA bundle {} is not readable by child policy", + path.display() + ); + let mut file = open_readonly_without_following_symlink(&path)?; + let metadata = file + .metadata() + .with_context(|| format!("failed to stat CA bundle {}", path.display()))?; + anyhow::ensure!( + metadata.is_file(), + "CA bundle {} must be a regular file", + path.display() + ); + anyhow::ensure!( + metadata.len() <= MAX_CUSTOM_CA_BUNDLE_BYTES, + "CA bundle {} exceeds {MAX_CUSTOM_CA_BUNDLE_BYTES} bytes", + path.display() + ); + let opened_path = opened_file_path(&path, &file)?; + anyhow::ensure!( + can_read_path(&opened_path), + "CA bundle {} is not readable by child policy", + opened_path.display() + ); + validate_opened_file_path(&path, &opened_path, &metadata)?; + + let mut bytes = Vec::with_capacity(metadata.len() as usize); + std::io::Read::by_ref(&mut file) + .take(MAX_CUSTOM_CA_BUNDLE_BYTES + 1) + .read_to_end(&mut bytes) + .with_context(|| format!("failed to read CA bundle {}", path.display()))?; + anyhow::ensure!( + bytes.len() as u64 <= MAX_CUSTOM_CA_BUNDLE_BYTES, + "CA bundle {} exceeds {MAX_CUSTOM_CA_BUNDLE_BYTES} bytes", + path.display() + ); + String::from_utf8(bytes) + .with_context(|| format!("CA bundle {} must be valid UTF-8", path.display())) +} + +pub(crate) fn read_custom_ca_dir(dir: &Path, can_read_path: F) -> Result +where + F: Fn(&Path) -> bool, +{ + anyhow::ensure!( + can_read_path(dir), + "CA directory {} is not readable by child policy", + dir.display() + ); + let dir = dir + .canonicalize() + .with_context(|| format!("failed to resolve CA directory {}", dir.display()))?; + anyhow::ensure!( + can_read_path(&dir), + "CA directory {} is not readable by child policy", + dir.display() + ); + anyhow::ensure!( + dir.metadata() + .with_context(|| format!("failed to stat CA directory {}", dir.display()))? + .is_dir(), + "CA directory {} must be a directory", + dir.display() + ); + + let mut trust_bundle = String::new(); + for entry in fs::read_dir(&dir) + .with_context(|| format!("failed to read CA directory {}", dir.display()))? + { + let entry = entry + .with_context(|| format!("failed to read CA directory entry in {}", dir.display()))?; + let path = entry.path(); + match read_custom_ca_bundle(&path, &can_read_path) { + Ok(contents) => append_bounded_pem_contents(&mut trust_bundle, &contents)?, + Err(err) => { + warn!( + ca_bundle_path = %path.display(), + "failed to read CA directory entry; skipping it: {err}" + ); + } + } + } + + Ok(trust_bundle) +} + +fn persist_ca_trust_bundle(proxy_dir: &Path, trust_bundle: &str) -> Result { fs::create_dir_all(proxy_dir) .with_context(|| format!("failed to create {}", proxy_dir.display()))?; let hash = Sha256::digest(trust_bundle.as_bytes()); @@ -240,16 +391,177 @@ fn persist_managed_ca_trust_bundle( Ok(trust_bundle_path) } +fn matches_generated_trust_bundle_path(path: &Path, proxy_dir: &Path) -> bool { + let Some(file_name) = path.file_name().and_then(|file_name| file_name.to_str()) else { + return false; + }; + path.parent() == Some(proxy_dir) + && file_name.starts_with(&format!("{MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX}-")) + && file_name.ends_with(".pem") +} + fn append_pem_file(bundle: &mut String, path: &Path) -> Result<()> { - if !bundle.ends_with('\n') { - bundle.push('\n'); - } let pem = fs::read_to_string(path) .with_context(|| format!("failed to read CA bundle {}", path.display()))?; - bundle.push_str(&pem); + append_pem_contents(bundle, &pem); + Ok(()) +} + +pub(crate) fn append_pem_contents(bundle: &mut String, pem: &str) { + if !bundle.is_empty() && !bundle.ends_with('\n') { + bundle.push('\n'); + } + bundle.push_str(pem); if !bundle.ends_with('\n') { bundle.push('\n'); } +} + +pub(crate) fn append_bounded_pem_contents(bundle: &mut String, pem: &str) -> Result<()> { + let separator_len = usize::from(!bundle.is_empty() && !bundle.ends_with('\n')); + let trailing_newline_len = usize::from(!pem.ends_with('\n')); + anyhow::ensure!( + (bundle.len() + separator_len + pem.len() + trailing_newline_len) as u64 + <= MAX_CUSTOM_CA_BUNDLE_BYTES, + "CA directory exceeds {MAX_CUSTOM_CA_BUNDLE_BYTES} bytes" + ); + append_pem_contents(bundle, pem); + Ok(()) +} + +fn open_readonly_without_following_symlink(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + options + .open(path) + .with_context(|| format!("failed to open CA bundle {}", path.display())) +} + +fn opened_file_path(path: &Path, _file: &File) -> Result { + #[cfg(target_os = "linux")] + { + let opened_path = fs::read_link(format!("/proc/self/fd/{}", _file.as_raw_fd())) + .with_context(|| format!("failed to resolve opened CA bundle {}", path.display()))?; + opened_path + .canonicalize() + .with_context(|| format!("failed to canonicalize opened CA bundle {}", path.display())) + } + + #[cfg(target_os = "macos")] + { + let mut opened_path = vec![0_u8; libc::PATH_MAX as usize]; + // SAFETY: fcntl writes at most PATH_MAX bytes into the provided writable buffer. + let result = + unsafe { libc::fcntl(_file.as_raw_fd(), libc::F_GETPATH, opened_path.as_mut_ptr()) }; + anyhow::ensure!( + result != -1, + "failed to resolve opened CA bundle {}: {}", + path.display(), + std::io::Error::last_os_error() + ); + let opened_path_len = opened_path + .iter() + .position(|byte| *byte == 0) + .unwrap_or(opened_path.len()); + PathBuf::from(OsStr::from_bytes(&opened_path[..opened_path_len])) + .canonicalize() + .with_context(|| format!("failed to canonicalize opened CA bundle {}", path.display())) + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + #[cfg(windows)] + { + use windows_sys::Win32::Storage::FileSystem::FILE_NAME_NORMALIZED; + use windows_sys::Win32::Storage::FileSystem::GetFinalPathNameByHandleW; + use windows_sys::Win32::Storage::FileSystem::VOLUME_NAME_DOS; + + let mut opened_path = vec![0_u16; 512]; + loop { + // SAFETY: `_file` owns a live OS handle and `opened_path` is writable for its + // declared capacity. + let length = unsafe { + GetFinalPathNameByHandleW( + _file.as_raw_handle() as _, + opened_path.as_mut_ptr(), + opened_path.len() as u32, + FILE_NAME_NORMALIZED | VOLUME_NAME_DOS, + ) + }; + anyhow::ensure!( + length != 0, + "failed to resolve opened CA bundle {}: {}", + path.display(), + std::io::Error::last_os_error() + ); + if length < opened_path.len() as u32 { + opened_path.truncate(length as usize); + break; + } + opened_path.resize(length as usize + 1, 0); + } + PathBuf::from(OsString::from_wide(&opened_path)) + .canonicalize() + .with_context(|| { + format!("failed to canonicalize opened CA bundle {}", path.display()) + }) + } + + #[cfg(not(windows))] + { + path.canonicalize() + .with_context(|| format!("failed to resolve CA bundle {}", path.display())) + } + } +} + +fn validate_opened_file_path( + path: &Path, + opened_path: &Path, + metadata: &fs::Metadata, +) -> Result<()> { + #[cfg(unix)] + { + let opened_path_metadata = fs::metadata(opened_path).with_context(|| { + format!("failed to stat opened CA bundle {}", opened_path.display()) + })?; + anyhow::ensure!( + metadata.dev() == opened_path_metadata.dev() + && metadata.ino() == opened_path_metadata.ino(), + "CA bundle {} changed before it could be validated", + path.display() + ); + } + + #[cfg(not(unix))] + { + #[cfg(windows)] + { + let opened_path_metadata = fs::metadata(opened_path).with_context(|| { + format!("failed to stat opened CA bundle {}", opened_path.display()) + })?; + anyhow::ensure!( + metadata.volume_serial_number() == opened_path_metadata.volume_serial_number() + && metadata.file_index() == opened_path_metadata.file_index(), + "CA bundle {} changed before it could be validated", + path.display() + ); + } + + #[cfg(not(windows))] + { + let _ = path; + let _ = opened_path; + let _ = metadata; + } + } + Ok(()) } @@ -507,17 +819,100 @@ mod tests { )); } + #[test] + fn current_generated_trust_bundle_path_rejects_hash_mismatch() { + let dir = tempdir().unwrap(); + let managed_ca_cert_path = dir.path().join("ca.pem"); + let trust_bundle_path = dir.path().join("ca-bundle-123.pem"); + fs::write(&managed_ca_cert_path, "managed ca\n").unwrap(); + fs::write(&trust_bundle_path, "custom ca\nmanaged ca\n").unwrap(); + assert!(!is_current_generated_trust_bundle_path( + &trust_bundle_path, + &managed_ca_cert_path, + )); + } + #[test] fn managed_ca_trust_bundle_records_startup_ca_env_values() { let dir = tempdir().unwrap(); let managed_ca_cert_path = dir.path().join("ca.pem"); fs::write(&managed_ca_cert_path, "managed ca\n").unwrap(); - let env = HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())]); + let env = HashMap::from([ + ("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string()), + (SSL_CERT_DIR_ENV_KEY, "/tmp/startup-certs".to_string()), + ]); let trust_bundle = managed_ca_trust_bundle_for_cert_path(&managed_ca_cert_path, &env).unwrap(); assert_eq!( trust_bundle.startup_env_values, - HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())]) + HashMap::from([ + ("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string()), + (SSL_CERT_DIR_ENV_KEY, "/tmp/startup-certs".to_string()), + ]) + ); + } + + #[test] + fn managed_ca_trust_bundle_does_not_append_startup_ca_override_to_baseline() { + let dir = tempdir().unwrap(); + let managed_ca_cert_path = dir.path().join("ca.pem"); + let startup_ca_bundle_path = dir.path().join("startup-ca.pem"); + fs::write(&managed_ca_cert_path, "managed ca\n").unwrap(); + fs::write(&startup_ca_bundle_path, "startup ca\n").unwrap(); + let env = HashMap::from([( + "SSL_CERT_FILE", + startup_ca_bundle_path.display().to_string(), + )]); + + let trust_bundle = + managed_ca_trust_bundle_for_cert_path(&managed_ca_cert_path, &env).unwrap(); + let baseline_bundle = fs::read_to_string(trust_bundle.path).unwrap(); + + assert!(!baseline_bundle.contains("startup ca")); + assert!(baseline_bundle.contains("managed ca")); + } + + #[test] + fn read_custom_ca_bundle_rejects_non_regular_file() { + let dir = tempdir().unwrap(); + + let err = read_custom_ca_bundle(dir.path(), |_| true).unwrap_err(); + + assert!( + err.to_string().contains("must be a regular file") + || err.to_string().contains("failed to open CA bundle"), + "unexpected error: {err:#}" + ); + } + + #[cfg(unix)] + #[test] + fn read_custom_ca_bundle_reads_readable_symlink() { + let dir = tempdir().unwrap(); + let ca_bundle_path = dir.path().join("ca.pem"); + let symlink_path = dir.path().join("ca-link.pem"); + fs::write(&ca_bundle_path, "custom ca\n").unwrap(); + std::os::unix::fs::symlink(&ca_bundle_path, &symlink_path).unwrap(); + + assert_eq!( + read_custom_ca_bundle(&symlink_path, |_| true).unwrap(), + "custom ca\n" + ); + } + + #[cfg(windows)] + #[test] + fn opened_file_path_uses_windows_handle_target() { + let dir = tempdir().unwrap(); + let checked_path = dir.path().join("checked.pem"); + let opened_path = dir.path().join("opened.pem"); + fs::write(&checked_path, "checked ca\n").unwrap(); + fs::write(&opened_path, "opened ca\n").unwrap(); + let file = File::open(&opened_path).unwrap(); + + assert_eq!( + opened_file_path(&checked_path, &file).unwrap(), + opened_path.canonicalize().unwrap() ); } diff --git a/codex-rs/network-proxy/src/child_ca.rs b/codex-rs/network-proxy/src/child_ca.rs new file mode 100644 index 0000000000..c0f4e267c3 --- /dev/null +++ b/codex-rs/network-proxy/src/child_ca.rs @@ -0,0 +1,203 @@ +use crate::certs::ManagedMitmCaTrustBundle; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashMap; +use std::path::Path; +use tracing::warn; + +pub(crate) fn prepare_mitm_ca_trust_bundle_env( + mitm_ca_trust_bundle: &ManagedMitmCaTrustBundle, + env: &mut HashMap, + cwd: &Path, + startup_ca_env_keys_present_in_child: &[&'static str], + can_read_path: F, +) -> Vec +where + F: Fn(&Path) -> bool, +{ + let ssl_cert_dir_contents = read_child_ca_dir_contents( + mitm_ca_trust_bundle, + env, + cwd, + startup_ca_env_keys_present_in_child, + &can_read_path, + ); + // Fold SSL_CERT_DIR into SSL_CERT_FILE so children cannot consult an + // unmaterialized CA directory after preparation. + env.remove(crate::certs::SSL_CERT_DIR_ENV_KEY); + let mut materialized_ca_trust_bundle_paths = Vec::new(); + for key in crate::certs::CUSTOM_CA_ENV_KEYS { + let Some(value) = env.get(key).filter(|value| !value.is_empty()) else { + continue; + }; + let mut custom_ca_bundle = read_child_ca_bundle_contents( + mitm_ca_trust_bundle, + key, + value, + cwd, + startup_ca_env_keys_present_in_child, + &can_read_path, + ) + .unwrap_or_default(); + if key == "SSL_CERT_FILE" + && let Some(ssl_cert_dir_contents) = ssl_cert_dir_contents.as_deref() + && let Err(err) = crate::certs::append_bounded_pem_contents( + &mut custom_ca_bundle, + ssl_cert_dir_contents, + ) + { + warn!( + ca_env_key = key, + "failed to combine child MITM CA bundle; leaving current value unchanged: {err}" + ); + continue; + } + if custom_ca_bundle.is_empty() { + continue; + } + + match crate::certs::materialize_ca_trust_bundle_with_custom_ca( + mitm_ca_trust_bundle, + &custom_ca_bundle, + ) { + Ok(path) => { + env.insert(key.to_string(), path.to_string_lossy().into_owned()); + materialized_ca_trust_bundle_paths.push(path); + } + Err(err) => { + warn!( + ca_env_key = key, + "failed to materialize child MITM CA bundle; leaving current value unchanged: {err}" + ); + } + } + } + + managed_mitm_ca_trust_bundle_paths_for_env( + mitm_ca_trust_bundle, + env, + &materialized_ca_trust_bundle_paths, + ) +} + +fn resolve_ca_bundle_path(path: &str, cwd: &Path) -> std::path::PathBuf { + let path = Path::new(path); + if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + } +} + +fn read_child_ca_bundle_contents( + mitm_ca_trust_bundle: &ManagedMitmCaTrustBundle, + key: &'static str, + value: &str, + cwd: &Path, + startup_ca_env_keys_present_in_child: &[&'static str], + can_read_path: &F, +) -> Option +where + F: Fn(&Path) -> bool, +{ + let value_path = Path::new(value); + let custom_ca_bundle_path = if startup_ca_env_keys_present_in_child.contains(&key) { + let startup_value = mitm_ca_trust_bundle.startup_env_values.get(key)?; + resolve_ca_bundle_path(startup_value, &mitm_ca_trust_bundle.startup_cwd) + } else if value_path == mitm_ca_trust_bundle.path { + return None; + } else { + resolve_ca_bundle_path(value, cwd) + }; + match crate::certs::read_custom_ca_bundle(&custom_ca_bundle_path, can_read_path) { + Ok(contents) => Some(contents), + Err(err) => { + warn!( + ca_env_key = key, + ca_bundle_path = %custom_ca_bundle_path.display(), + "failed to read child MITM CA bundle; leaving current value unchanged: {err}" + ); + None + } + } +} + +fn read_child_ca_dir_contents( + mitm_ca_trust_bundle: &ManagedMitmCaTrustBundle, + env: &HashMap, + cwd: &Path, + startup_ca_env_keys_present_in_child: &[&'static str], + can_read_path: &F, +) -> Option +where + F: Fn(&Path) -> bool, +{ + let value = env + .get(crate::certs::SSL_CERT_DIR_ENV_KEY) + .filter(|value| !value.is_empty())?; + let ca_dir_cwd = + if startup_ca_env_keys_present_in_child.contains(&crate::certs::SSL_CERT_DIR_ENV_KEY) { + &mitm_ca_trust_bundle.startup_cwd + } else { + cwd + }; + let mut trust_bundle = String::new(); + for ca_dir_path in std::env::split_paths(value).map(|path| { + if path.is_absolute() { + path + } else { + ca_dir_cwd.join(path) + } + }) { + match crate::certs::read_custom_ca_dir(&ca_dir_path, can_read_path) { + Ok(contents) if !contents.is_empty() => { + if let Err(err) = + crate::certs::append_bounded_pem_contents(&mut trust_bundle, &contents) + { + warn!( + ca_bundle_path = %ca_dir_path.display(), + "failed to combine child MITM CA directories; ignoring SSL_CERT_DIR override: {err}" + ); + return None; + } + } + Ok(_) => {} + Err(err) => { + warn!( + ca_bundle_path = %ca_dir_path.display(), + "failed to read child MITM CA directory; skipping it: {err}" + ); + } + } + } + if trust_bundle.is_empty() { + None + } else { + Some(trust_bundle) + } +} + +fn managed_mitm_ca_trust_bundle_paths_for_env( + mitm_ca_trust_bundle: &ManagedMitmCaTrustBundle, + env: &HashMap, + materialized_ca_trust_bundle_paths: &[std::path::PathBuf], +) -> Vec { + let mut paths = crate::certs::CUSTOM_CA_ENV_KEYS + .into_iter() + .filter_map(|key| env.get(key)) + .map(Path::new) + .filter(|path| { + *path == mitm_ca_trust_bundle.path + || materialized_ca_trust_bundle_paths + .iter() + .any(|materialized_path| path == materialized_path) + }) + .filter_map(|path| AbsolutePathBuf::from_absolute_path(path).ok()) + .collect::>(); + paths.sort(); + paths.dedup(); + paths +} + +#[cfg(test)] +#[path = "child_ca_tests.rs"] +mod tests; diff --git a/codex-rs/network-proxy/src/child_ca_tests.rs b/codex-rs/network-proxy/src/child_ca_tests.rs new file mode 100644 index 0000000000..4fb38c548a --- /dev/null +++ b/codex-rs/network-proxy/src/child_ca_tests.rs @@ -0,0 +1,218 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::Path; +use tempfile::tempdir; + +const REQUESTS_CA_BUNDLE_ENV_KEY: &str = "REQUESTS_CA_BUNDLE"; + +fn test_mitm_ca_trust_bundle( + dir: &tempfile::TempDir, + startup_env_values: HashMap<&'static str, String>, +) -> ManagedMitmCaTrustBundle { + let path = dir.path().join("ca-bundle.pem"); + fs::write(&path, "managed ca\n").unwrap(); + ManagedMitmCaTrustBundle { + path, + startup_env_values, + startup_cwd: dir.path().to_path_buf(), + } +} + +fn requests_ca_bundle_env(value: impl Into) -> HashMap { + HashMap::from([(REQUESTS_CA_BUNDLE_ENV_KEY.to_string(), value.into())]) +} + +fn requests_ca_bundle_contents(env: &HashMap) -> String { + fs::read_to_string(Path::new( + env.get(REQUESTS_CA_BUNDLE_ENV_KEY) + .expect("REQUESTS_CA_BUNDLE should be set"), + )) + .unwrap() +} + +fn ssl_cert_dir_env( + dir: &tempfile::TempDir, + contents: [String; 2], +) -> (HashMap, ManagedMitmCaTrustBundle) { + let ssl_cert_dir_paths = [dir.path().join("certs-a"), dir.path().join("certs-b")]; + for (path, contents) in ssl_cert_dir_paths.iter().zip(contents) { + fs::create_dir(path).unwrap(); + fs::write(path.join("ordinary-ca.pem"), contents).unwrap(); + } + let mitm_ca_trust_bundle_path = dir.path().join("ca-bundle.pem"); + fs::write(&mitm_ca_trust_bundle_path, "managed ca\n").unwrap(); + let ssl_cert_dir = std::env::join_paths(["certs-a", "certs-b"]).unwrap(); + let ssl_cert_dir = ssl_cert_dir.to_string_lossy().into_owned(); + ( + HashMap::from([ + ( + "SSL_CERT_FILE".to_string(), + mitm_ca_trust_bundle_path.display().to_string(), + ), + ( + crate::certs::SSL_CERT_DIR_ENV_KEY.to_string(), + ssl_cert_dir.clone(), + ), + ]), + ManagedMitmCaTrustBundle { + path: mitm_ca_trust_bundle_path, + startup_env_values: HashMap::from([(crate::certs::SSL_CERT_DIR_ENV_KEY, ssl_cert_dir)]), + startup_cwd: dir.path().to_path_buf(), + }, + ) +} + +#[test] +fn materializes_readable_startup_ca_override() { + let dir = tempdir().unwrap(); + let startup_ca_bundle_path = dir.path().join("startup-ca.pem"); + let command_cwd = dir.path().join("command-cwd"); + fs::create_dir(&command_cwd).unwrap(); + fs::write(&startup_ca_bundle_path, "startup ca\n").unwrap(); + let mitm_ca_trust_bundle = test_mitm_ca_trust_bundle( + &dir, + HashMap::from([(REQUESTS_CA_BUNDLE_ENV_KEY, "startup-ca.pem".to_string())]), + ); + let mut env = requests_ca_bundle_env("startup-ca.pem"); + + let bundle_paths = prepare_mitm_ca_trust_bundle_env( + &mitm_ca_trust_bundle, + &mut env, + &command_cwd, + &[REQUESTS_CA_BUNDLE_ENV_KEY], + |_| true, + ); + + assert_eq!( + requests_ca_bundle_contents(&env), + "startup ca\nmanaged ca\n" + ); + assert_eq!(bundle_paths.len(), 1); +} + +#[test] +fn does_not_restore_filtered_startup_override() { + let dir = tempdir().unwrap(); + let mitm_ca_trust_bundle = test_mitm_ca_trust_bundle( + &dir, + HashMap::from([(REQUESTS_CA_BUNDLE_ENV_KEY, "startup-ca.pem".to_string())]), + ); + let mut env = requests_ca_bundle_env(mitm_ca_trust_bundle.path.display().to_string()); + + let bundle_paths = + prepare_mitm_ca_trust_bundle_env(&mitm_ca_trust_bundle, &mut env, dir.path(), &[], |_| { + true + }); + + assert_eq!( + env.get(REQUESTS_CA_BUNDLE_ENV_KEY), + Some(&mitm_ca_trust_bundle.path.display().to_string()) + ); + assert_eq!(bundle_paths.len(), 1); +} + +#[test] +fn materializes_readable_command_scoped_override() { + let dir = tempdir().unwrap(); + let command_ca_bundle_path = dir.path().join("command-ca.pem"); + fs::write(&command_ca_bundle_path, "command ca\n").unwrap(); + let mut env = requests_ca_bundle_env("command-ca.pem"); + let mitm_ca_trust_bundle = test_mitm_ca_trust_bundle(&dir, HashMap::new()); + + prepare_mitm_ca_trust_bundle_env(&mitm_ca_trust_bundle, &mut env, dir.path(), &[], |_| true); + + assert_eq!( + requests_ca_bundle_contents(&env), + "command ca\nmanaged ca\n" + ); +} + +#[test] +fn materializes_readable_ssl_cert_dir() { + let dir = tempdir().unwrap(); + let (mut env, mitm_ca_trust_bundle) = + ssl_cert_dir_env(&dir, ["dir ca a\n".to_string(), "dir ca b\n".to_string()]); + + prepare_mitm_ca_trust_bundle_env( + &mitm_ca_trust_bundle, + &mut env, + dir.path(), + &[crate::certs::SSL_CERT_DIR_ENV_KEY], + |_| true, + ); + + let ssl_cert_file_path = Path::new( + env.get("SSL_CERT_FILE") + .expect("SSL_CERT_FILE should be set"), + ); + assert_eq!( + fs::read_to_string(ssl_cert_file_path).unwrap(), + "dir ca a\ndir ca b\nmanaged ca\n" + ); + assert_eq!(env.get(crate::certs::SSL_CERT_DIR_ENV_KEY), None); +} + +#[test] +fn bounds_aggregate_ssl_cert_dir_contents() { + let dir = tempdir().unwrap(); + let oversized_dir_contents = "a".repeat(2_200_000); + let (mut env, mitm_ca_trust_bundle) = ssl_cert_dir_env( + &dir, + [oversized_dir_contents.clone(), oversized_dir_contents], + ); + + prepare_mitm_ca_trust_bundle_env( + &mitm_ca_trust_bundle, + &mut env, + dir.path(), + &[crate::certs::SSL_CERT_DIR_ENV_KEY], + |_| true, + ); + + assert_eq!( + env.get("SSL_CERT_FILE"), + Some(&mitm_ca_trust_bundle.path.display().to_string()) + ); + assert_eq!(env.get(crate::certs::SSL_CERT_DIR_ENV_KEY), None); +} + +#[test] +fn preserves_unreadable_command_scoped_override() { + let dir = tempdir().unwrap(); + let command_ca_bundle_path = dir.path().join("command-ca.pem"); + fs::write(&command_ca_bundle_path, "command ca\n").unwrap(); + let mut env = requests_ca_bundle_env("command-ca.pem"); + let mitm_ca_trust_bundle = test_mitm_ca_trust_bundle(&dir, HashMap::new()); + + let bundle_paths = + prepare_mitm_ca_trust_bundle_env(&mitm_ca_trust_bundle, &mut env, dir.path(), &[], |_| { + false + }); + + assert_eq!( + env.get(REQUESTS_CA_BUNDLE_ENV_KEY), + Some(&"command-ca.pem".to_string()) + ); + assert!(bundle_paths.is_empty()); +} + +#[test] +fn does_not_whitelist_existing_generated_bundle_override() { + let dir = tempdir().unwrap(); + let generated_ca_bundle_path = dir.path().join("ca-bundle-handcrafted.pem"); + fs::write(&generated_ca_bundle_path, "extra ca\nmanaged ca\n").unwrap(); + let mut env = requests_ca_bundle_env(generated_ca_bundle_path.display().to_string()); + let mitm_ca_trust_bundle = test_mitm_ca_trust_bundle(&dir, HashMap::new()); + + let bundle_paths = + prepare_mitm_ca_trust_bundle_env(&mitm_ca_trust_bundle, &mut env, dir.path(), &[], |_| { + false + }); + + assert_eq!( + env.get(REQUESTS_CA_BUNDLE_ENV_KEY), + Some(&generated_ca_bundle_path.display().to_string()) + ); + assert!(bundle_paths.is_empty()); +} diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index d3e8db33c4..259a9128ed 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -1,11 +1,13 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] mod certs; +mod child_ca; mod config; mod connect_policy; mod http_proxy; mod mitm; mod mitm_hook; +mod native_certs; mod network_policy; mod policy; mod proxy; @@ -17,6 +19,7 @@ mod state; mod upstream; pub use certs::CUSTOM_CA_ENV_KEYS; +pub use certs::SSL_CERT_DIR_ENV_KEY; pub use certs::is_managed_mitm_ca_trust_bundle_path; pub use config::NetworkDomainPermission; pub use config::NetworkDomainPermissionEntry; @@ -47,6 +50,7 @@ pub use proxy::Args; #[cfg(target_os = "macos")] pub use proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; pub use proxy::DEFAULT_NO_PROXY_VALUE; +pub use proxy::MITM_CA_ENV_ACTIVE_ENV_KEY; pub use proxy::NO_PROXY_ENV_KEYS; pub use proxy::NetworkProxy; pub use proxy::NetworkProxyBuilder; diff --git a/codex-rs/network-proxy/src/native_certs.rs b/codex-rs/network-proxy/src/native_certs.rs new file mode 100644 index 0000000000..0ab697375c --- /dev/null +++ b/codex-rs/network-proxy/src/native_certs.rs @@ -0,0 +1,260 @@ +#[cfg(any(target_os = "macos", windows))] +use rama_tls_rustls::dep::pki_types::CertificateDer; +use rustls_native_certs::CertificateResult; +#[cfg(any(target_os = "macos", windows))] +use rustls_native_certs::Error; +#[cfg(any(target_os = "macos", windows))] +use rustls_native_certs::ErrorKind; + +// `rustls_native_certs::load_native_certs()` first consults SSL_CERT_FILE and +// SSL_CERT_DIR. The managed baseline must contain platform roots only; child +// CA overrides are copied later after the child filesystem policy can be checked. +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + let mut result = + rustls_native_certs::load_certs_from_paths(platform_cert_file().as_deref(), None); + for cert_dir in platform_cert_dirs() { + extend_certificate_result( + &mut result, + rustls_native_certs::load_certs_from_paths(None, Some(&cert_dir)), + ); + } + dedupe_certs(&mut result); + result +} + +#[cfg(target_os = "macos")] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + use security_framework::trust_settings::Domain; + use security_framework::trust_settings::TrustSettings; + use security_framework::trust_settings::TrustSettingsForCertificate; + use std::collections::BTreeMap; + + let mut result = CertificateResult::default(); + let mut all_certs = BTreeMap::new(); + for domain in &[Domain::User, Domain::Admin, Domain::System] { + let ts = TrustSettings::new(*domain); + let iter = match ts.iter() { + Ok(iter) => iter, + Err(err) => { + result.errors.push(Error { + context: match domain { + Domain::User => "failed to load user trust settings", + Domain::Admin => "failed to load admin trust settings", + Domain::System => "failed to load system trust settings", + }, + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + + for cert in iter { + let der = cert.to_der(); + let trusted = match ts.tls_trust_settings_for_certificate(&cert) { + Ok(trusted) => trusted.unwrap_or(TrustSettingsForCertificate::TrustRoot), + Err(err) => { + result.errors.push(Error { + context: "certificate not trusted", + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + all_certs.entry(der).or_insert(trusted); + } + } + + for (der, trusted) in all_certs { + use TrustSettingsForCertificate::*; + + if let TrustRoot | TrustAsRoot = trusted { + result.certs.push(CertificateDer::from(der)); + } + } + result +} + +#[cfg(windows)] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + use schannel::cert_store::CertStore; + + let mut result = CertificateResult::default(); + let current_user_store = match CertStore::open_current_user("ROOT") { + Ok(store) => store, + Err(err) => { + result.errors.push(Error { + context: "failed to open current user certificate store", + kind: ErrorKind::Os(err.into()), + }); + return result; + } + }; + + for cert in current_user_store.certs() { + let valid_uses = match cert.valid_uses() { + Ok(valid_uses) => valid_uses, + Err(err) => { + result.errors.push(Error { + context: "failed to inspect certificate valid uses", + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + let is_time_valid = match cert.is_time_valid() { + Ok(is_time_valid) => is_time_valid, + Err(err) => { + result.errors.push(Error { + context: "failed to inspect certificate time validity", + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + if usable_for_rustls(valid_uses) && is_time_valid { + result + .certs + .push(CertificateDer::from(cert.to_der().to_vec())); + } + } + result +} + +#[cfg(not(any(all(unix, not(target_os = "macos")), target_os = "macos", windows)))] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + rustls_native_certs::load_native_certs() +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn extend_certificate_result(result: &mut CertificateResult, extra: CertificateResult) { + result.certs.extend(extra.certs); + result.errors.extend(extra.errors); +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn dedupe_certs(result: &mut CertificateResult) { + result.certs.sort_unstable_by(|a, b| a.cmp(b)); + result.certs.dedup(); +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn platform_cert_file() -> Option { + PLATFORM_CERTIFICATE_FILE_NAMES + .iter() + .map(std::path::Path::new) + .find(|path| path.exists()) + .map(std::path::Path::to_path_buf) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn platform_cert_dirs() -> impl Iterator { + PLATFORM_CERTIFICATE_DIRS + .iter() + .map(std::path::Path::new) + .filter(|path| path.exists()) + .map(std::path::Path::to_path_buf) +} + +#[cfg(all(unix, not(target_os = "macos"), target_os = "linux"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &[ + "/etc/ssl/certs", + "/etc/pki/tls/certs", + "/etc/security/certificates", +]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "freebsd"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/ssl/certs", "/usr/local/share/certs"]; + +#[cfg(all( + unix, + not(target_os = "macos"), + any(target_os = "illumos", target_os = "solaris") +))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/certs/CA"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "netbsd"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/openssl/certs"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "aix"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/var/ssl/certs"]; + +#[cfg(all( + unix, + not(target_os = "macos"), + not(any( + target_os = "linux", + target_os = "freebsd", + target_os = "illumos", + target_os = "solaris", + target_os = "netbsd", + target_os = "aix" + )) +))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/ssl/certs"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "linux"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &[ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem", + "/etc/pki/tls/cacert.pem", + "/etc/ssl/cert.pem", + "/opt/etc/ssl/certs/ca-certificates.crt", + "/etc/ssl/certs/cacert.pem", +]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "freebsd"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/usr/local/etc/ssl/cert.pem"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "dragonfly"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/usr/local/share/certs/ca-root-nss.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "netbsd"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/openssl/certs/ca-certificates.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "openbsd"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/ssl/cert.pem"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "solaris"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/certs/ca-certificates.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "illumos"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = + &["/etc/ssl/cacert.pem", "/etc/certs/ca-certificates.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "android"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = + &["/data/data/com.termux/files/usr/etc/tls/cert.pem"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "haiku"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/boot/system/data/ssl/CARootCertificates.pem"]; + +#[cfg(all( + unix, + not(target_os = "macos"), + not(any( + target_os = "linux", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "netbsd", + target_os = "openbsd", + target_os = "solaris", + target_os = "illumos", + target_os = "android", + target_os = "haiku", + )) +))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/ssl/certs/ca-certificates.crt"]; + +#[cfg(windows)] +fn usable_for_rustls(uses: schannel::cert_context::ValidUses) -> bool { + match uses { + schannel::cert_context::ValidUses::All => true, + schannel::cert_context::ValidUses::Oids(strs) => strs.iter().any(|x| x == PKIX_SERVER_AUTH), + } +} + +#[cfg(windows)] +const PKIX_SERVER_AUTH: &str = "1.3.6.1.5.5.7.3.1"; diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index c3685a4310..c8c7fe5194 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -13,6 +13,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::net::SocketAddr; use std::net::TcpListener as StdTcpListener; +use std::path::Path; use std::sync::Arc; use std::sync::Mutex; use std::sync::RwLock; @@ -308,6 +309,7 @@ impl NetworkProxyRuntimeSettings { let mitm_ca_trust_bundle = if config.network.mitm { let env = crate::certs::CUSTOM_CA_ENV_KEYS .into_iter() + .chain(std::iter::once(crate::certs::SSL_CERT_DIR_ENV_KEY)) .filter_map(|key| std::env::var(key).ok().map(|value| (key, value))) .collect(); Some(crate::certs::managed_ca_trust_bundle(&env)?) @@ -376,6 +378,8 @@ pub const PROXY_URL_ENV_KEYS: &[&str] = &[ pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"]; pub const PROXY_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_ACTIVE"; +pub const MITM_CA_ENV_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_MITM_CA_ENV_ACTIVE"; +const STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY: &str = "CODEX_NETWORK_PROXY_STARTUP_CA_ENV_KEYS_PRESENT"; pub const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING"; const ELECTRON_GET_USE_PROXY_ENV_KEY: &str = "ELECTRON_GET_USE_PROXY"; const NODE_USE_ENV_PROXY_ENV_KEY: &str = "NODE_USE_ENV_PROXY"; @@ -383,6 +387,8 @@ const NODE_USE_ENV_PROXY_ENV_KEY: &str = "NODE_USE_ENV_PROXY"; const GIT_SSH_COMMAND_ENV_KEY: &str = "GIT_SSH_COMMAND"; pub const PROXY_ENV_KEYS: &[&str] = &[ PROXY_ACTIVE_ENV_KEY, + MITM_CA_ENV_ACTIVE_ENV_KEY, + STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY, ALLOW_LOCAL_BINDING_ENV_KEY, ELECTRON_GET_USE_PROXY_ENV_KEY, NODE_USE_ENV_PROXY_ENV_KEY, @@ -571,27 +577,55 @@ fn apply_proxy_env_overrides( } if let Some(mitm_ca_trust_bundle) = mitm_ca_trust_bundle { + env.insert(MITM_CA_ENV_ACTIVE_ENV_KEY.to_string(), "1".to_string()); let managed_path = mitm_ca_trust_bundle.path.to_string_lossy().into_owned(); + let startup_ca_env_keys_present_in_child = ca_env_keys() + .filter(|&key| { + env.get(key) + .filter(|value| !value.is_empty()) + .is_some_and(|value| { + mitm_ca_trust_bundle.startup_env_values.get(key) == Some(value) + || (value == &managed_path && is_tracked_startup_ca_env_key(env, key)) + }) + }) + .collect::>(); + if startup_ca_env_keys_present_in_child.is_empty() { + env.remove(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY); + } else { + env.insert( + STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY.to_string(), + startup_ca_env_keys_present_in_child.join(","), + ); + } for key in crate::certs::CUSTOM_CA_ENV_KEYS { if env .get(key) .filter(|value| !value.is_empty()) .is_some_and(|value| { - value != &managed_path - && mitm_ca_trust_bundle.startup_env_values.get(key) != Some(value) + value != &managed_path && !startup_ca_env_keys_present_in_child.contains(&key) }) { - // TODO(winston): Materialize policy-checked per-child bundles for readable - // startup and command-scoped CA overrides. For now startup overrides are - // replaced with the default bundle and later command-scoped overrides are - // preserved, either of which can make intercepted TLS fail. continue; } env.insert(key.to_string(), managed_path.clone()); } + } else { + env.remove(MITM_CA_ENV_ACTIVE_ENV_KEY); + env.remove(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY); } } +fn ca_env_keys() -> impl Iterator { + crate::certs::CUSTOM_CA_ENV_KEYS + .into_iter() + .chain(std::iter::once(crate::certs::SSL_CERT_DIR_ENV_KEY)) +} + +fn is_tracked_startup_ca_env_key(env: &HashMap, key: &str) -> bool { + env.get(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY) + .is_some_and(|keys| keys.split(',').any(|tracked_key| tracked_key == key)) +} + impl NetworkProxy { pub fn builder() -> NetworkProxyBuilder { NetworkProxyBuilder::default() @@ -643,7 +677,7 @@ impl NetworkProxy { pub fn apply_to_env(&self, env: &mut HashMap) { let runtime_settings = self.runtime_settings(); // Enforce proxying for child processes. Proxy endpoint values are always rewritten; - // managed MITM CA vars preserve child-scoped overrides after proxy startup. + // managed MITM CA vars preserve command-scoped overrides after proxy startup. apply_proxy_env_overrides( env, self.http_addr, @@ -654,6 +688,43 @@ impl NetworkProxy { ); } + /// Rewrites readable child-selected CA bundles into immutable managed MITM bundles. + pub fn prepare_child_env( + &self, + env: &mut HashMap, + cwd: &Path, + can_read_path: F, + ) -> Vec + where + F: Fn(&Path) -> bool, + { + let runtime_settings = self.runtime_settings(); + apply_proxy_env_overrides( + env, + self.http_addr, + self.socks_addr, + self.socks_enabled, + runtime_settings.allow_local_binding, + runtime_settings.mitm_ca_trust_bundle.as_ref(), + ); + let startup_ca_env_keys_present_in_child = ca_env_keys() + .filter(|&key| is_tracked_startup_ca_env_key(env, key)) + .collect::>(); + env.remove(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY); + runtime_settings.mitm_ca_trust_bundle.as_ref().map_or_else( + Vec::new, + |mitm_ca_trust_bundle| { + crate::child_ca::prepare_mitm_ca_trust_bundle_env( + mitm_ca_trust_bundle, + env, + cwd, + &startup_ca_env_keys_present_in_child, + can_read_path, + ) + }, + ) + } + pub async fn replace_config_state(&self, new_state: ConfigState) -> Result<()> { let current_cfg = self.state.current_cfg().await?; anyhow::ensure!( @@ -1110,6 +1181,7 @@ mod tests { let mitm_ca_trust_bundle = crate::certs::ManagedMitmCaTrustBundle { path: mitm_ca_trust_bundle_path.to_path_buf(), startup_env_values: HashMap::new(), + startup_cwd: Path::new("/tmp").to_path_buf(), }; apply_proxy_env_overrides( &mut env, @@ -1129,18 +1201,18 @@ mod tests { } #[test] - fn apply_proxy_env_overrides_preserves_command_scoped_mitm_ca_override() { - let command_ca_bundle_path = "/tmp/command-ca.pem".to_string(); + fn apply_proxy_env_overrides_tracks_rewritten_startup_mitm_ca_override() { + let startup_ca_bundle_path = "/tmp/startup-ca.pem".to_string(); let mut env = HashMap::from([( "REQUESTS_CA_BUNDLE".to_string(), - command_ca_bundle_path.clone(), + startup_ca_bundle_path.clone(), )]); let mitm_ca_trust_bundle_path = Path::new("/tmp/codex-proxy/ca-bundle.pem"); let mitm_ca_trust_bundle = crate::certs::ManagedMitmCaTrustBundle { path: mitm_ca_trust_bundle_path.to_path_buf(), - startup_env_values: HashMap::new(), + startup_env_values: HashMap::from([("REQUESTS_CA_BUNDLE", startup_ca_bundle_path)]), + startup_cwd: Path::new("/tmp").to_path_buf(), }; - apply_proxy_env_overrides( &mut env, SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128), @@ -1150,11 +1222,37 @@ mod tests { Some(&mitm_ca_trust_bundle), ); - assert_eq!(env.get("REQUESTS_CA_BUNDLE"), Some(&command_ca_bundle_path)); + assert_eq!( + env.get("REQUESTS_CA_BUNDLE"), + Some(&mitm_ca_trust_bundle_path.display().to_string()) + ); + assert_eq!( + env.get(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY), + Some(&"REQUESTS_CA_BUNDLE".to_string()) + ); assert_eq!( env.get("SSL_CERT_FILE"), Some(&mitm_ca_trust_bundle_path.display().to_string()) ); + + env.insert( + "REQUESTS_CA_BUNDLE".to_string(), + "/tmp/command-ca.pem".to_string(), + ); + apply_proxy_env_overrides( + &mut env, + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128), + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), + /*socks_enabled*/ true, + /*allow_local_binding*/ false, + Some(&mitm_ca_trust_bundle), + ); + + assert_eq!( + env.get("REQUESTS_CA_BUNDLE"), + Some(&"/tmp/command-ca.pem".to_string()) + ); + assert_eq!(env.get(STARTUP_CA_ENV_KEYS_PRESENT_ENV_KEY), None); } #[test] diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index 11bd26133b..bd7a5f0058 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -35,6 +35,7 @@ fn rate_limit_snapshot() -> RateLimitSnapshot { resets_at: Some(secondary_reset_at), }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index cbf8895ddb..5a08610cd6 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -374,16 +374,9 @@ impl Default for PermissionProfile { impl PermissionProfile { /// Managed read-only filesystem access with restricted network access. pub fn read_only() -> 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/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 0214f450fd..4b678a9209 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -22,6 +22,7 @@ use crate::config_types::ReasoningSummary; use crate::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use crate::config_types::ServiceTier; use crate::config_types::Verbosity; +use crate::protocol::MultiAgentVersion; const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}"; pub const SPEED_TIER_FAST: &str = "fast"; @@ -347,6 +348,12 @@ pub struct ModelInfo { deserialize_with = "deserialize_optional_model_selector" )] pub tool_mode: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_optional_model_selector" + )] + pub multi_agent_version: Option, } impl ModelInfo { @@ -643,6 +650,7 @@ mod tests { supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, } } @@ -901,6 +909,22 @@ mod tests { assert!(!object.contains_key("tool_mode")); } + #[test] + fn model_info_treats_unknown_multi_agent_version_as_omitted() { + let mut value = + serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model"); + let object = value + .as_object_mut() + .expect("model info should be an object"); + object.insert( + "multi_agent_version".to_string(), + serde_json::Value::String("future_multi_agent_version".to_string()), + ); + let model = serde_json::from_value::(value).expect("deserialize model info"); + + assert_eq!(model.multi_agent_version, None); + } + #[test] fn resolved_context_window_prefers_context_window() { let model = ModelInfo { 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, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7c91223f72..2674492e88 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2009,6 +2009,7 @@ pub struct RateLimitSnapshot { pub primary: Option, pub secondary: Option, pub credits: Option, + pub individual_limit: Option, pub plan_type: Option, pub rate_limit_reached_type: Option, } @@ -2058,6 +2059,14 @@ pub struct CreditsSnapshot { pub balance: Option, } +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] +pub struct SpendControlLimitSnapshot { + pub limit: String, + pub used: String, + pub remaining_percent: i32, + pub resets_at: i64, +} + // Includes prompts, tools and space to call compact. const BASELINE_TOKENS: i64 = 12000; @@ -2441,6 +2450,18 @@ impl InitialHistory { } } + pub fn get_multi_agent_version(&self) -> Option { + match self { + InitialHistory::New | InitialHistory::Cleared => None, + InitialHistory::Resumed(resumed) => { + multi_agent_version_from_items(&resumed.history, Some(resumed.conversation_id)) + } + InitialHistory::Forked(items) => { + multi_agent_version_from_items(items, /*thread_id*/ None) + } + } + } + pub fn get_resumed_session_sources(&self) -> Option<(SessionSource, Option)> { let meta = self.get_resumed_session_meta()?; Some((meta.source.clone(), meta.thread_source)) @@ -2711,6 +2732,39 @@ impl fmt::Display for InternalSessionSource { } } +fn multi_agent_version_from_items( + items: &[RolloutItem], + thread_id: Option, +) -> Option { + let session_meta_version = items.iter().rev().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) + if thread_id.is_none_or(|thread_id| meta_line.meta.id == thread_id) => + { + meta_line.meta.multi_agent_version + } + _ => None, + }); + + session_meta_version.or_else(|| { + items.iter().rev().find_map(|item| match item { + RolloutItem::TurnContext(turn_context) => turn_context.multi_agent_version, + RolloutItem::SessionMeta(_) + | RolloutItem::ResponseItem(_) + | RolloutItem::Compacted(_) + | RolloutItem::EventMsg(_) => None, + }) + }) +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum MultiAgentVersion { + Disabled, + V1, + V2, +} + /// SessionMeta contains session-level data that doesn't correspond to a specific turn. /// /// NOTE: There used to be an `instructions` field here, which stored user_instructions, but we @@ -2750,6 +2804,8 @@ pub struct SessionMeta { pub dynamic_tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub memory_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub multi_agent_version: Option, } impl Default for SessionMeta { @@ -2771,6 +2827,7 @@ impl Default for SessionMeta { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, } } } @@ -2850,6 +2907,8 @@ pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub collaboration_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub multi_agent_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub realtime_active: Option, #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option, @@ -5271,6 +5330,39 @@ mod tests { Ok(()) } + #[test] + fn multi_agent_version_uses_newest_present_session_meta_value() -> Result<()> { + let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; + let older_meta = SessionMetaLine { + meta: SessionMeta { + id: thread_id, + multi_agent_version: Some(MultiAgentVersion::V2), + ..Default::default() + }, + git: None, + }; + let newer_meta_without_version = SessionMetaLine { + meta: SessionMeta { + id: thread_id, + multi_agent_version: None, + ..Default::default() + }, + git: None, + }; + + assert_eq!( + multi_agent_version_from_items( + &[ + RolloutItem::SessionMeta(older_meta), + RolloutItem::SessionMeta(newer_meta_without_version), + ], + Some(thread_id), + ), + Some(MultiAgentVersion::V2) + ); + Ok(()) + } + #[test] fn turn_context_item_serializes_network_when_present() -> Result<()> { let item = TurnContextItem { @@ -5297,6 +5389,7 @@ mod tests { model: "gpt-5".to_string(), personality: None, collaboration_mode: None, + multi_agent_version: None, realtime_active: None, effort: None, summary: ReasoningSummaryConfig::Auto, diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index be6b88ef52..1752363ec5 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, @@ -71,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/rollout/src/compression_tests.rs b/codex-rs/rollout/src/compression_tests.rs index 8eb2380536..eb81112965 100644 --- a/codex-rs/rollout/src/compression_tests.rs +++ b/codex-rs/rollout/src/compression_tests.rs @@ -23,6 +23,7 @@ use crate::RolloutConfig; use crate::RolloutRecorder; use crate::RolloutRecorderParams; use crate::append_rollout_item_to_path; +use crate::search_rollout_matches; #[tokio::test] async fn load_rollout_items_reads_compressed_rollout() -> anyhow::Result<()> { @@ -104,6 +105,31 @@ async fn append_rollout_item_materializes_compressed_rollout() -> anyhow::Result Ok(()) } +#[tokio::test] +async fn search_rollout_matches_returns_compressed_snippet() -> anyhow::Result<()> { + let home = TempDir::new()?; + let uuid = Uuid::from_u128(15); + let thread_id = ThreadId::from_string(&uuid.to_string())?; + let rollout_path = rollout_path(home.path(), "2025-01-03T12-00-00", uuid); + write_rollout(&rollout_path, thread_id, "targeted search term")?; + compress_now(&rollout_path)?; + let compressed_path = compressed_rollout_path(&rollout_path); + + let matches = search_rollout_matches( + std::path::Path::new("missing-rg-for-test"), + home.path(), + /*archived*/ false, + "search term", + ) + .await?; + + assert_eq!( + matches.get(compressed_path.as_path()), + Some(&Some("targeted search term".to_string())) + ); + Ok(()) +} + #[tokio::test] async fn worker_compresses_old_archived_rollouts_only() -> anyhow::Result<()> { let home = TempDir::new()?; @@ -447,6 +473,7 @@ fn write_rollout(path: &std::path::Path, thread_id: ThreadId, message: &str) -> base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }; diff --git a/codex-rs/rollout/src/lib.rs b/codex-rs/rollout/src/lib.rs index ec9fc27f34..9e6db0fb8d 100644 --- a/codex-rs/rollout/src/lib.rs +++ b/codex-rs/rollout/src/lib.rs @@ -67,6 +67,7 @@ pub use recorder::RolloutRecorder; pub use recorder::RolloutRecorderParams; pub use recorder::append_rollout_item_to_path; pub use search::first_rollout_content_match_snippet; +pub use search::search_rollout_matches; pub use search::search_rollout_paths; pub use session_index::append_thread_name; pub use session_index::find_thread_meta_by_name_str; diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 7ae3843085..b53c958ae8 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -49,6 +49,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }; let session_meta_line = SessionMetaLine { meta: session_meta, @@ -102,9 +103,11 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }; let polluted_meta = SessionMeta { memory_mode: Some("polluted".to_string()), + multi_agent_version: None, ..session_meta.clone() }; let lines = vec![ @@ -363,6 +366,7 @@ fn write_rollout_in_sessions_with_cwd( base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }; let session_meta_line = SessionMetaLine { meta: session_meta, diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 94e3efe2ec..d102d5c540 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -52,6 +52,7 @@ use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_protocol::protocol::GitInfo as ProtocolGitInfo; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::ResumedHistory; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; @@ -87,6 +88,7 @@ pub enum RolloutRecorderParams { thread_source: Option, base_instructions: BaseInstructions, dynamic_tools: Vec, + multi_agent_version: Option, }, Resume { path: PathBuf, @@ -172,9 +174,24 @@ impl RolloutRecorderParams { thread_source, base_instructions, dynamic_tools, + multi_agent_version: None, } } + pub fn with_multi_agent_version( + mut self, + multi_agent_version: Option, + ) -> Self { + if let Self::Create { + multi_agent_version: version, + .. + } = &mut self + { + *version = multi_agent_version; + } + self + } + pub fn resume(path: PathBuf) -> Self { Self::Resume { path } } @@ -661,6 +678,7 @@ impl RolloutRecorder { thread_source, base_instructions, dynamic_tools, + multi_agent_version, } => { let log_file_info = precompute_log_file_info(config, conversation_id)?; let path = log_file_info.path.clone(); @@ -696,6 +714,7 @@ impl RolloutRecorder { Some(dynamic_tools) }, memory_mode: (!config.generate_memories()).then_some("disabled".to_string()), + multi_agent_version, }; (None, Some(log_file_info), path, Some(session_meta)) diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index a9ee6ca9aa..07029abe37 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -99,6 +99,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }; @@ -1145,6 +1146,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re model: "test-model".to_string(), personality: None, collaboration_mode: None, + multi_agent_version: None, realtime_active: None, effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, diff --git a/codex-rs/rollout/src/search.rs b/codex-rs/rollout/src/search.rs index 2270913548..705d24e7b4 100644 --- a/codex-rs/rollout/src/search.rs +++ b/codex-rs/rollout/src/search.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::collections::HashSet; use std::io; use std::path::Path; @@ -20,21 +21,42 @@ use super::compression; const MATCH_CONTEXT_BEFORE_CHARS: usize = 48; const MATCH_CONTEXT_AFTER_CHARS: usize = 96; +pub type RolloutSearchMatches = HashMap>; + pub async fn search_rollout_paths( rg_command: &Path, codex_home: &Path, archived: bool, search_term: &str, ) -> io::Result> { + Ok( + search_rollout_matches(rg_command, codex_home, archived, search_term) + .await? + .into_keys() + .collect(), + ) +} + +pub async fn search_rollout_matches( + rg_command: &Path, + codex_home: &Path, + archived: bool, + search_term: &str, +) -> io::Result { let root = codex_home.join(if archived { ARCHIVED_SESSIONS_SUBDIR } else { SESSIONS_SUBDIR }); - let search_term = json_escaped_search_term(search_term)?; - let mut matches = - ripgrep_rollout_paths(rg_command, root.as_path(), search_term.as_str()).await?; - matches.extend(scan_compressed_rollout_paths(root.as_path(), search_term.as_str()).await?); + let json_search_term = json_escaped_search_term(search_term)?; + let Some(plain_matches) = + ripgrep_rollout_paths(rg_command, root.as_path(), json_search_term.as_str()).await? + else { + return scan_rollout_matches(root.as_path(), json_search_term.as_str(), search_term).await; + }; + let mut matches: RolloutSearchMatches = + plain_matches.into_iter().map(|path| (path, None)).collect(); + matches.extend(scan_compressed_rollout_matches(root.as_path(), search_term).await?); Ok(matches) } @@ -42,9 +64,9 @@ async fn ripgrep_rollout_paths( rg_command: &Path, root: &Path, search_term: &str, -) -> io::Result> { +) -> io::Result>> { if !tokio::fs::try_exists(root).await.unwrap_or(false) { - return Ok(HashSet::new()); + return Ok(Some(HashSet::new())); } let output = match Command::new(rg_command) @@ -62,13 +84,13 @@ async fn ripgrep_rollout_paths( { Ok(output) => output, Err(err) if err.kind() == io::ErrorKind::NotFound => { - return scan_rollout_paths(root, search_term).await; + return Ok(None); } Err(err) => return Err(err), }; if !output.status.success() { if output.status.code() == Some(1) && output.stderr.is_empty() { - return Ok(HashSet::new()); + return Ok(Some(HashSet::new())); } return Err(io::Error::other(format!( @@ -88,13 +110,17 @@ async fn ripgrep_rollout_paths( matches.insert(path); } - Ok(matches) + Ok(Some(matches)) } -async fn scan_rollout_paths(root: &Path, search_term: &str) -> io::Result> { - let mut matches = HashSet::new(); +async fn scan_rollout_matches( + root: &Path, + json_search_term: &str, + search_term: &str, +) -> io::Result { + let mut matches = HashMap::new(); let mut dirs = vec![root.to_path_buf()]; - let search_term = case_insensitive_literal_regex(search_term)?; + let json_search_term = case_insensitive_literal_regex(json_search_term)?; while let Some(dir) = dirs.pop() { let mut entries = match tokio::fs::read_dir(dir).await { @@ -115,8 +141,16 @@ async fn scan_rollout_paths(root: &Path, search_term: &str) -> io::Result io::Result> { - let mut matches = HashSet::new(); +) -> io::Result { + let mut matches = HashMap::new(); let mut dirs = vec![root.to_path_buf()]; - let search_term = case_insensitive_literal_regex(search_term)?; while let Some(dir) = dirs.pop() { let mut entries = match tokio::fs::read_dir(dir).await { @@ -181,8 +214,10 @@ async fn scan_compressed_rollout_paths( if !rollout_file.is_compressed() { continue; } - if rollout_contains(rollout_file.path(), &search_term).await? { - matches.insert(rollout_file.into_path()); + if let Some(snippet) = + first_rollout_content_match_snippet(rollout_file.path(), search_term).await? + { + matches.insert(rollout_file.into_path(), Some(snippet)); } } } diff --git a/codex-rs/rollout/src/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs index 91b475caff..97496d394e 100644 --- a/codex-rs/rollout/src/session_index_tests.rs +++ b/codex-rs/rollout/src/session_index_tests.rs @@ -41,6 +41,7 @@ fn write_rollout_with_metadata(path: &Path, thread_id: ThreadId) -> std::io::Res base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }), diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index 22a3cb803a..a84ede199b 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -158,6 +158,7 @@ fn write_rollout_with_user_message( base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }), diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 73431b0412..e489eaba59 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -1274,6 +1274,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }), diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index 2dda65f9d5..99cd521fb9 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -19,7 +19,7 @@ pub use manager::SandboxType; pub use manager::SandboxablePreference; pub use manager::compatibility_sandbox_policy_for_permission_profile; pub use manager::get_platform_sandbox; -pub use manager::with_managed_mitm_ca_readable_root; +pub use manager::prepare_managed_network_child; use codex_protocol::error::CodexErr; diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 202a30bb9c..b0aad1ce6e 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -13,6 +13,7 @@ use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::permissions::ReadDenyMatcher; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; @@ -61,20 +62,18 @@ pub fn get_platform_sandbox(windows_sandbox_enabled: bool) -> Option, + managed_mitm_ca_trust_bundle_paths: &[AbsolutePathBuf], sandbox_policy_cwd: &Path, ) -> PermissionProfile { - let Some(managed_mitm_ca_trust_bundle_path) = managed_mitm_ca_trust_bundle_path else { + if managed_mitm_ca_trust_bundle_paths.is_empty() { return permission_profile; - }; + } let (file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); - let file_system_sandbox_policy = file_system_sandbox_policy.with_additional_readable_roots( - sandbox_policy_cwd, - std::slice::from_ref(managed_mitm_ca_trust_bundle_path), - ); + let file_system_sandbox_policy = file_system_sandbox_policy + .with_additional_readable_roots(sandbox_policy_cwd, managed_mitm_ca_trust_bundle_paths); PermissionProfile::from_runtime_permissions_with_enforcement( permission_profile.enforcement(), &file_system_sandbox_policy, @@ -82,6 +81,43 @@ pub fn with_managed_mitm_ca_readable_root( ) } +pub fn prepare_managed_network_child( + network: Option<&NetworkProxy>, + env: &mut HashMap, + command_cwd: &Path, + permission_profile: PermissionProfile, + sandbox_policy_cwd: &Path, +) -> PermissionProfile { + let managed_mitm_ca_trust_bundle_paths = network.map_or_else(Vec::new, |network| { + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); + let read_deny_matcher = + ReadDenyMatcher::new(&file_system_sandbox_policy, sandbox_policy_cwd); + network.prepare_child_env(env, command_cwd, |path| { + can_read_path_with_policy( + &file_system_sandbox_policy, + read_deny_matcher.as_ref(), + path, + sandbox_policy_cwd, + ) + }) + }); + with_managed_mitm_ca_readable_roots( + permission_profile, + &managed_mitm_ca_trust_bundle_paths, + sandbox_policy_cwd, + ) +} + +fn can_read_path_with_policy( + file_system_sandbox_policy: &FileSystemSandboxPolicy, + read_deny_matcher: Option<&ReadDenyMatcher>, + path: &Path, + cwd: &Path, +) -> bool { + file_system_sandbox_policy.can_read_path_with_cwd(path, cwd) + && !read_deny_matcher.is_some_and(|matcher| matcher.is_read_denied(path)) +} + #[derive(Debug)] pub struct SandboxCommand { pub program: OsString, @@ -203,13 +239,13 @@ impl SandboxManager { windows_sandbox_private_desktop, } = request; let additional_permissions = command.additional_permissions.take(); - let managed_mitm_ca_trust_bundle_path = - network.and_then(NetworkProxy::managed_mitm_ca_trust_bundle_path); let effective_permission_profile = effective_permission_profile(permissions, additional_permissions.as_ref()); - let effective_permission_profile = with_managed_mitm_ca_readable_root( + let effective_permission_profile = prepare_managed_network_child( + network, + &mut command.env, + command.cwd.as_path(), effective_permission_profile, - managed_mitm_ca_trust_bundle_path.as_ref(), sandbox_policy_cwd, ); let (effective_file_system_policy, effective_network_policy) = @@ -246,7 +282,8 @@ impl SandboxManager { let exe = codex_linux_sandbox_exe .ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?; let allow_proxy_network = allow_network_for_proxy(enforce_managed_network); - let mitm_ca_cert_path = network.and_then(NetworkProxy::mitm_ca_cert_path); + let mitm_ca_cert_path = + network.and_then(NetworkProxy::managed_mitm_ca_trust_bundle_path); #[cfg(target_os = "linux")] ensure_linux_bubblewrap_is_supported( &effective_file_system_policy, diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index fede00fe1e..6019a22066 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -3,8 +3,9 @@ use super::SandboxManager; use super::SandboxTransformRequest; use super::SandboxType; use super::SandboxablePreference; +use super::can_read_path_with_policy; use super::get_platform_sandbox; -use super::with_managed_mitm_ca_readable_root; +use super::with_managed_mitm_ca_readable_roots; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions; @@ -16,6 +17,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::permissions::ReadDenyMatcher; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize; use pretty_assertions::assert_eq; @@ -261,9 +263,9 @@ fn managed_mitm_ca_bundle_becomes_readable_for_restricted_sandbox() { NetworkSandboxPolicy::Restricted, ); - let permission_profile = with_managed_mitm_ca_readable_root( + let permission_profile = with_managed_mitm_ca_readable_roots( permission_profile, - Some(&managed_bundle_path), + std::slice::from_ref(&managed_bundle_path), cwd.as_path(), ); let (file_system_sandbox_policy, _) = permission_profile.to_runtime_permissions(); @@ -285,6 +287,44 @@ fn managed_mitm_ca_bundle_becomes_readable_for_restricted_sandbox() { ); } +#[test] +fn managed_mitm_ca_materialization_rejects_glob_denied_paths_from_command_subdir() { + let sandbox_policy_cwd = TempDir::new().expect("create cwd"); + let sandbox_policy_cwd = AbsolutePathBuf::from_absolute_path( + canonicalize(sandbox_policy_cwd.path()).expect("canonicalize cwd"), + ) + .expect("absolute cwd"); + let command_cwd = sandbox_policy_cwd.join("subdir"); + std::fs::create_dir(command_cwd.as_path()).expect("create command cwd"); + let ca_bundle_path = command_cwd.join("../secrets/blocked.pem"); + std::fs::create_dir(sandbox_policy_cwd.join("secrets").as_path()).expect("create secrets"); + std::fs::write(ca_bundle_path.as_path(), "secret").expect("write blocked CA bundle"); + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: sandbox_policy_cwd.clone(), + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "secrets/**".to_string(), + }, + access: FileSystemAccessMode::Deny, + }, + ]); + let read_deny_matcher = + ReadDenyMatcher::new(&file_system_sandbox_policy, sandbox_policy_cwd.as_path()) + .expect("deny matcher"); + + assert!(!can_read_path_with_policy( + &file_system_sandbox_policy, + Some(&read_deny_matcher), + ca_bundle_path.as_path(), + sandbox_policy_cwd.as_path(), + )); +} + #[cfg(target_os = "linux")] fn transform_linux_seccomp_request( codex_linux_sandbox_exe: &std::path::Path, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index f64e6f7fed..1bba63fbf3 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -336,6 +336,7 @@ mod tests { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }), @@ -357,6 +358,7 @@ mod tests { model: "gpt-5".to_string(), personality: None, collaboration_mode: None, + multi_agent_version: None, realtime_active: None, effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -394,6 +396,7 @@ mod tests { model: "gpt-5".to_string(), personality: None, collaboration_mode: None, + multi_agent_version: None, realtime_active: None, effort: None, summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -428,6 +431,7 @@ mod tests { model: "gpt-5".to_string(), personality: None, collaboration_mode: None, + multi_agent_version: None, realtime_active: None, effort: Some(ReasoningEffort::High), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -458,6 +462,7 @@ mod tests { model: "gpt-5".to_string(), personality: None, collaboration_mode: None, + multi_agent_version: None, realtime_active: None, effort: Some(ReasoningEffort::High), summary: codex_protocol::config_types::ReasoningSummary::Auto, @@ -494,6 +499,7 @@ mod tests { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: None, }), diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 9522b6176d..4cc7ee4dc1 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1332,6 +1332,7 @@ mod tests { base_instructions: None, dynamic_tools: None, memory_mode: Some("polluted".to_string()), + multi_agent_version: None, }, git: None, })]; @@ -1392,6 +1393,7 @@ mod tests { base_instructions: None, dynamic_tools: None, memory_mode: None, + multi_agent_version: None, }, git: Some(GitInfo { commit_hash: Some(codex_git_utils::GitSha::new("rollout-sha")), diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 02e64a1ff9..b02fc15855 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -11,6 +11,9 @@ use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::ThreadMemoryMode; use crate::AppendThreadItemsParams; use crate::ArchiveThreadParams; @@ -164,7 +167,32 @@ impl ThreadStore for InMemoryThreadStore { async fn create_thread(&self, params: CreateThreadParams) -> ThreadStoreResult<()> { let mut state = self.state.lock().await; state.calls.create_thread += 1; - state.histories.entry(params.thread_id).or_default(); + let session_meta = SessionMeta { + id: params.thread_id, + forked_from_id: params.forked_from_id, + parent_thread_id: params.parent_thread_id, + cwd: params.metadata.cwd.clone().unwrap_or_default(), + agent_nickname: params.source.get_nickname(), + agent_role: params.source.get_agent_role(), + agent_path: params.source.get_agent_path().map(Into::into), + source: params.source.clone(), + thread_source: params.thread_source, + model_provider: Some(params.metadata.model_provider.clone()), + base_instructions: Some(params.base_instructions.clone()), + dynamic_tools: (!params.dynamic_tools.is_empty()).then(|| params.dynamic_tools.clone()), + memory_mode: matches!(params.metadata.memory_mode, ThreadMemoryMode::Disabled) + .then_some("disabled".to_string()), + multi_agent_version: params.multi_agent_version, + ..SessionMeta::default() + }; + state + .histories + .entry(params.thread_id) + .or_default() + .push(RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta, + git: None, + })); state.created_threads.insert(params.thread_id, params); Ok(()) } @@ -172,7 +200,11 @@ impl ThreadStore for InMemoryThreadStore { async fn resume_thread(&self, params: ResumeThreadParams) -> ThreadStoreResult<()> { let mut state = self.state.lock().await; state.calls.resume_thread += 1; - state.histories.entry(params.thread_id).or_default(); + if let Some(history) = params.history { + state.histories.insert(params.thread_id, history); + } else { + state.histories.entry(params.thread_id).or_default(); + } if let Some(rollout_path) = params.rollout_path { state.rollout_paths.insert(rollout_path, params.thread_id); } diff --git a/codex-rs/thread-store/src/local/create_thread.rs b/codex-rs/thread-store/src/local/create_thread.rs index a71bb48b0f..8c630f9956 100644 --- a/codex-rs/thread-store/src/local/create_thread.rs +++ b/codex-rs/thread-store/src/local/create_thread.rs @@ -35,7 +35,8 @@ pub(super) async fn create_thread( params.thread_source, params.base_instructions, params.dynamic_tools, - ), + ) + .with_multi_agent_version(params.multi_agent_version), ) .await .map_err(|err| ThreadStoreError::Internal { diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 85fa6ad78c..146bfbd843 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -1019,6 +1019,7 @@ mod tests { thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), + multi_agent_version: None, metadata: thread_metadata(), } } diff --git a/codex-rs/thread-store/src/local/search_threads.rs b/codex-rs/thread-store/src/local/search_threads.rs index ed9ad3a0a2..20f5da8243 100644 --- a/codex-rs/thread-store/src/local/search_threads.rs +++ b/codex-rs/thread-store/src/local/search_threads.rs @@ -7,7 +7,7 @@ use codex_rollout::RolloutConfig; use codex_rollout::find_thread_names_by_ids; use codex_rollout::first_rollout_content_match_snippet; use codex_rollout::parse_cursor; -use codex_rollout::search_rollout_paths; +use codex_rollout::search_rollout_matches; use super::LocalThreadStore; use super::helpers::distinct_thread_metadata_title; @@ -64,7 +64,7 @@ pub(super) async fn search_threads( generate_memories: false, }; let rg_command = InstallContext::current().rg_command(); - let matching_paths = search_rollout_paths( + let matching_rollouts = search_rollout_matches( rg_command.as_path(), store.config.codex_home.as_path(), params.archived, @@ -74,7 +74,7 @@ pub(super) async fn search_threads( .map_err(|err| ThreadStoreError::Internal { message: format!("failed to search rollout contents: {err}"), })?; - if matching_paths.is_empty() { + if matching_rollouts.is_empty() { return Ok(ThreadSearchPage { items: Vec::new(), next_cursor: None, @@ -95,7 +95,7 @@ pub(super) async fn search_threads( search_term: None, use_state_db_only: state_db.is_some(), }; - let mut remaining_paths = matching_paths; + let mut remaining_rollouts = matching_rollouts; loop { let page = list_rollout_threads( @@ -109,16 +109,15 @@ pub(super) async fn search_threads( ) .await?; for item in page.items { - if !remaining_paths.remove(item.path.as_path()) { - continue; - } - let Some(snippet) = - first_rollout_content_match_snippet(item.path.as_path(), search_term) + let Some(snippet) = (match remaining_rollouts.remove(item.path.as_path()) { + Some(Some(snippet)) => Some(snippet), + Some(None) => first_rollout_content_match_snippet(item.path.as_path(), search_term) .await .map_err(|err| ThreadStoreError::Internal { message: format!("failed to read rollout search match: {err}"), - })? - else { + })?, + None => None, + }) else { continue; }; matching_items.push(ThreadSearchItem { item, snippet }); @@ -128,7 +127,7 @@ pub(super) async fn search_threads( } page_cursor = page.next_cursor; if matching_items.len() > params.page_size - || remaining_paths.is_empty() + || remaining_rollouts.is_empty() || page_cursor.is_none() { break; diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 7c09259a67..1ab42a3117 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -9,6 +9,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GitInfo; +use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadMemoryMode as MemoryMode; @@ -72,6 +73,8 @@ pub struct CreateThreadParams { pub base_instructions: BaseInstructions, /// Dynamic tools available to the thread at startup. pub dynamic_tools: Vec, + /// Multi-agent runtime selected when the thread was created. + pub multi_agent_version: Option, /// Metadata captured for the newly created thread. pub metadata: ThreadPersistenceMetadata, } 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. diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 165d6efa20..1b4d789394 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -46,6 +46,7 @@ fn model_with_shell_type(shell_type: ConfigShellToolType) -> ModelInfo { supports_search_tool: false, auto_review_model_override: None, tool_mode: None, + multi_agent_version: None, } } diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 18e6116e58..4aeba59aab 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -77,7 +77,7 @@ impl App { } ServerNotification::AccountRateLimitsUpdated(notification) => { self.chat_widget - .on_rate_limit_snapshot(Some(notification.rate_limits.clone())); + .on_rolling_rate_limit_snapshot(notification.rate_limits.clone()); return; } ServerNotification::AccountUpdated(notification) => { 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/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 92ed5d470c..ec72c5974a 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -14,19 +14,32 @@ pub(super) struct WindowsSetupPermissions { pub(super) workspace_roots: Vec, } +async fn build_config_on_runtime_worker( + builder: ConfigBuilder, + error_context: String, +) -> Result { + match tokio::spawn(async move { builder.build().await }).await { + Ok(build_result) => build_result.wrap_err(error_context), + Err(err) if err.is_panic() => std::panic::resume_unwind(err.into_panic()), + Err(err) => Err(err).wrap_err_with(|| format!("{error_context} task failed")), + } +} + impl App { pub(super) async fn rebuild_config_for_cwd(&self, cwd: PathBuf) -> Result { let mut overrides = self.harness_overrides.clone(); overrides.cwd = Some(cwd.clone()); let cwd_display = cwd.display().to_string(); - ConfigBuilder::default() + let builder = ConfigBuilder::default() .codex_home(self.config.codex_home.to_path_buf()) .cli_overrides(self.cli_kv_overrides.clone()) .harness_overrides(overrides) - .loader_overrides(self.loader_overrides.clone()) - .build() - .await - .wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}")) + .loader_overrides(self.loader_overrides.clone()); + build_config_on_runtime_worker( + builder, + format!("Failed to rebuild config for cwd {cwd_display}"), + ) + .await } pub(super) async fn rebuild_config_for_permission_profile( @@ -38,16 +51,16 @@ impl App { overrides.sandbox_mode = None; overrides.permission_profile = None; overrides.default_permissions = Some(profile_id.to_string()); - ConfigBuilder::default() + let builder = ConfigBuilder::default() .codex_home(self.config.codex_home.to_path_buf()) .cli_overrides(self.cli_kv_overrides.clone()) .harness_overrides(overrides) - .loader_overrides(self.loader_overrides.clone()) - .build() - .await - .wrap_err_with(|| { - format!("Failed to rebuild config for permission profile {profile_id}") - }) + .loader_overrides(self.loader_overrides.clone()); + build_config_on_runtime_worker( + builder, + format!("Failed to rebuild config for permission profile {profile_id}"), + ) + .await } #[cfg(target_os = "windows")] 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/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index f4e0ec101d..2290640ab4 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1804,6 +1804,7 @@ mod tests { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, } 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/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index df45a11c7d..f831b5a007 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -150,8 +150,27 @@ pub(super) fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) - matches!(info, AppServerCodexErrorInfo::CyberPolicy) } +#[derive(Clone, Copy)] +enum RateLimitSnapshotSource { + AccountUsage, + RollingUpdate, +} + impl ChatWidget { pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option) { + self.on_rate_limit_snapshot_from(snapshot, RateLimitSnapshotSource::AccountUsage); + } + + pub(crate) fn on_rolling_rate_limit_snapshot(&mut self, snapshot: RateLimitSnapshot) { + // Rolling app-server notifications are sparse. Preserve metadata learned from the full read. + self.on_rate_limit_snapshot_from(Some(snapshot), RateLimitSnapshotSource::RollingUpdate); + } + + fn on_rate_limit_snapshot_from( + &mut self, + snapshot: Option, + source: RateLimitSnapshotSource, + ) { if let Some(mut snapshot) = snapshot { let limit_id = snapshot .limit_id @@ -172,7 +191,16 @@ impl ChatWidget { balance: credits.balance.clone(), }); } - + let preserved_individual_limit = + if matches!(source, RateLimitSnapshotSource::RollingUpdate) + && snapshot.individual_limit.is_none() + { + self.rate_limit_snapshots_by_limit_id + .get(&limit_id) + .and_then(|display| display.individual_limit.clone()) + } else { + None + }; self.plan_type = snapshot.plan_type.or(self.plan_type); let is_codex_limit = limit_id.eq_ignore_ascii_case("codex"); @@ -234,8 +262,11 @@ impl ChatWidget { self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending; } - let display = + let mut display = rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now()); + if display.individual_limit.is_none() { + display.individual_limit = preserved_individual_limit; + } self.rate_limit_snapshots_by_limit_id .insert(limit_id, display); 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/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 04eb543baa..cfc9278ded 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -112,6 +112,7 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, } diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 4b13e53801..3d08daa427 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2,6 +2,7 @@ use super::*; use crate::bottom_pane::goal_status_indicator_line; use crate::chatwidget::rate_limits::NUDGE_MODEL_SLUG; use crate::chatwidget::rate_limits::get_limits_duration; +use codex_app_server_protocol::SpendControlLimitSnapshot; use pretty_assertions::assert_eq; use ratatui::backend::TestBackend; use serial_test::serial; @@ -580,6 +581,7 @@ async fn status_line_uses_secondary_fallback_for_unsupported_window() { resets_at: None, }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -608,6 +610,7 @@ async fn status_line_legacy_limit_items_prefer_matching_windows() { resets_at: None, }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -640,6 +643,7 @@ async fn status_line_shows_secondary_non_weekly_when_primary_is_weekly() { resets_at: None, }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -668,6 +672,7 @@ async fn status_line_five_hour_item_omits_weekly_only_limit() { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -696,6 +701,7 @@ async fn status_line_single_monthly_primary_omits_weekly_limit_item() { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -724,6 +730,7 @@ async fn status_line_secondary_only_non_weekly_limit_omits_primary_limit_item() resets_at: None, }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -752,6 +759,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { unlimited: false, balance: Some("17.5".to_string()), }), + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -772,6 +780,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -793,6 +802,40 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { ); } +#[tokio::test] +async fn rolling_rate_limit_snapshot_preserves_prior_individual_limit() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut usage_limits = snapshot(/*percent*/ 10.0); + usage_limits.individual_limit = Some(SpendControlLimitSnapshot { + limit: "25000".to_string(), + used: "8000".to_string(), + remaining_percent: 68, + resets_at: 1_800_000_000, + }); + chat.on_rate_limit_snapshot(Some(usage_limits)); + + chat.on_rolling_rate_limit_snapshot(snapshot(/*percent*/ 20.0)); + + let display = chat + .rate_limit_snapshots_by_limit_id + .get("codex") + .expect("rate limits should be cached"); + let individual_limit = display + .individual_limit + .as_ref() + .expect("rolling updates should preserve monthly limits"); + assert_eq!(individual_limit.used, "8,000"); + assert_eq!(individual_limit.limit, "25,000"); + assert_eq!(individual_limit.percent_remaining, 68.0); + + chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 30.0))); + let display = chat + .rate_limit_snapshots_by_limit_id + .get("codex") + .expect("rate limits should be cached"); + assert!(display.individual_limit.is_none()); +} + #[tokio::test] async fn rate_limit_snapshot_updates_and_retains_plan_type() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -811,6 +854,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { resets_at: None, }), credits: None, + individual_limit: None, plan_type: Some(PlanType::Plus), rate_limit_reached_type: None, })); @@ -830,6 +874,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { resets_at: Some(234), }), credits: None, + individual_limit: None, plan_type: Some(PlanType::Pro), rate_limit_reached_type: None, })); @@ -849,6 +894,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { resets_at: Some(567), }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -873,6 +919,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { unlimited: false, balance: Some("5.00".to_string()), }), + individual_limit: None, plan_type: Some(PlanType::Pro), rate_limit_reached_type: None, })); @@ -887,6 +934,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { }), secondary: None, credits: None, + individual_limit: None, plan_type: Some(PlanType::Pro), rate_limit_reached_type: None, })); @@ -940,6 +988,7 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index a90b8dcb27..8654b37d24 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -76,6 +76,7 @@ fn cache_rate_limit_snapshot(chat: &mut ChatWidget) { resets_at: None, }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); @@ -233,6 +234,7 @@ async fn status_surface_preview_omits_unavailable_rate_limit_items() { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); 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, }; 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, diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 0117508ae5..b8c9c16530 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -471,6 +471,7 @@ impl StatusHistoryCell { StatusRateLimitValue::Window { percent_used, resets_at, + details, } => { let percent_remaining = (100.0 - percent_used).clamp(0.0, 100.0); let summary = format_status_limit_summary(percent_remaining); @@ -522,6 +523,18 @@ impl StatusHistoryCell { } else { lines.push(base_line); } + if let Some(details) = details { + let detail_width = formatter.value_width(available_inner_width).max(1); + let wrap_options = textwrap::Options::new(detail_width).break_words(false); + lines.extend( + textwrap::wrap(details.as_str(), wrap_options) + .into_iter() + .map(|wrapped| { + formatter + .continuation(vec![Span::from(wrapped.into_owned()).dim()]) + }), + ); + } } StatusRateLimitValue::Text(text) => { let label = row.label.clone(); diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 1146bb8a31..2054f799d6 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -17,6 +17,8 @@ use chrono::Utc; use codex_app_server_protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; +use codex_app_server_protocol::SpendControlLimitSnapshot as CoreSpendControlLimitSnapshot; +use codex_protocol::num_format::format_with_separators; const STATUS_LIMIT_BAR_SEGMENTS: usize = 20; const STATUS_LIMIT_BAR_FILLED: &str = "█"; @@ -39,6 +41,8 @@ pub(crate) enum StatusRateLimitValue { percent_used: f64, /// Localized reset string, or `None` when unknown. resets_at: Option, + /// Optional detail line rendered beneath the progress bar. + details: Option, }, /// Plain text value used for non-window rows. Text(String), @@ -99,6 +103,8 @@ pub(crate) struct RateLimitSnapshotDisplay { pub secondary: Option, /// Optional credits metadata when available. pub credits: Option, + /// Optional effective monthly credit limit from workspace spend controls. + pub individual_limit: Option, } /// Display-ready credits state extracted from protocol snapshots. @@ -112,6 +118,17 @@ pub(crate) struct CreditsSnapshotDisplay { pub balance: Option, } +/// Display-ready effective monthly limit extracted from spend controls. +#[derive(Debug, Clone)] +pub(crate) struct SpendControlLimitSnapshotDisplay { + /// Local timestamp representing when this monthly usage value was captured. + pub captured_at: DateTime, + pub percent_remaining: f64, + pub used: String, + pub limit: String, + pub resets_at: Option, +} + /// Converts a protocol snapshot into UI-friendly display data. /// /// Pass the timestamp from the same observation point as `snapshot`; supplying a significantly @@ -141,6 +158,10 @@ pub(crate) fn rate_limit_snapshot_display_for_limit( .as_ref() .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)), credits: snapshot.credits.as_ref().map(CreditsSnapshotDisplay::from), + individual_limit: snapshot + .individual_limit + .as_ref() + .and_then(|limit| SpendControlLimitSnapshotDisplay::from_limit(limit, captured_at)), } } @@ -154,6 +175,22 @@ impl From<&CoreCreditsSnapshot> for CreditsSnapshotDisplay { } } +impl SpendControlLimitSnapshotDisplay { + fn from_limit( + value: &CoreSpendControlLimitSnapshot, + captured_at: DateTime, + ) -> Option { + Some(Self { + captured_at, + percent_remaining: f64::from(value.remaining_percent.clamp(0, 100)), + used: format_credit_amount(&value.used)?, + limit: format_credit_amount(&value.limit)?, + resets_at: DateTime::::from_timestamp(value.resets_at, 0) + .map(|dt| format_reset_timestamp(dt.with_timezone(&Local), captured_at)), + }) + } +} + /// Builds display rows from a snapshot and marks stale data by capture age. /// /// Callers should pass `Local::now()` for `now` at render time; using a cached timestamp can make @@ -182,6 +219,14 @@ pub(crate) fn compose_rate_limit_data_many( for snapshot in snapshots { stale |= now.signed_duration_since(snapshot.captured_at) > ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES); + stale |= snapshot + .individual_limit + .as_ref() + .map(|limit| { + now.signed_duration_since(limit.captured_at) + > ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES) + }) + .unwrap_or(false); let limit_bucket_label = snapshot.limit_name.clone(); let show_limit_prefix = !limit_bucket_label.eq_ignore_ascii_case("codex"); @@ -230,6 +275,7 @@ pub(crate) fn compose_rate_limit_data_many( value: StatusRateLimitValue::Window { percent_used: primary.used_percent, resets_at: primary.resets_at.clone(), + details: None, }, }); } @@ -256,6 +302,7 @@ pub(crate) fn compose_rate_limit_data_many( value: StatusRateLimitValue::Window { percent_used: secondary.used_percent, resets_at: secondary.resets_at.clone(), + details: None, }, }); } @@ -265,6 +312,19 @@ pub(crate) fn compose_rate_limit_data_many( { rows.push(row); } + if let Some(individual_limit) = snapshot.individual_limit.as_ref() { + rows.push(StatusRateLimitRow { + label: "Monthly credit limit".to_string(), + value: StatusRateLimitValue::Window { + percent_used: 100.0 - individual_limit.percent_remaining, + resets_at: individual_limit.resets_at.clone(), + details: Some(format!( + "{} of {} credits used", + individual_limit.used, individual_limit.limit + )), + }, + }); + } } if rows.is_empty() { @@ -341,6 +401,14 @@ fn format_credit_balance(raw: &str) -> Option { None } +fn format_credit_amount(raw: &str) -> Option { + let value = raw.trim().parse::().ok()?; + if !value.is_finite() || value < 0.0 { + return None; + } + Some(format_with_separators(value.round() as i64)) +} + #[cfg(test)] mod tests { use super::CreditsSnapshotDisplay; @@ -372,6 +440,7 @@ mod tests { unlimited: false, balance: Some("25".to_string()), }), + individual_limit: None, }; let other = RateLimitSnapshotDisplay { limit_name: "codex-other".to_string(), @@ -383,6 +452,7 @@ mod tests { unlimited: false, balance: Some("99".to_string()), }), + individual_limit: None, }; let rows = match compose_rate_limit_data_many(&[codex, other], now) { @@ -420,6 +490,7 @@ mod tests { window_minutes: Some(2 * 60), }), credits: None, + individual_limit: None, }; let rows = match compose_rate_limit_data_many(&[other], now) { diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap new file mode 100644 index 0000000000..765ad8d3a7 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭───────────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Monthly credit limit: [██████████████░░░░░░] 68% left (resets 07:08 on 7 May) │ +│ 8,000 of 25,000 credits used │ +╰───────────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal.snap new file mode 100644 index 0000000000..cf6fa2c2ec --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit │ +│ https://chatgpt.com/codex/settings/usage │ +│ for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace │ +│ Agents.md: │ +│ │ +│ Token usage: 1.2K total (800 │ +│ Context window: 100% left (1.2K u │ +│ Monthly credit limit: 68% left │ +│ (resets 07:08 on │ +│ 7 May) │ +│ 8,000 of 25,000 │ +│ credits used │ +╰────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 8b4e41ce76..1fc4c4feba 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -2,6 +2,11 @@ use super::new_status_output; use super::new_status_output_with_rate_limits; use super::new_status_output_with_rate_limits_handle; use super::rate_limit_snapshot_display; +use super::rate_limits::RateLimitSnapshotDisplay; +use super::rate_limits::RateLimitWindowDisplay; +use super::rate_limits::SpendControlLimitSnapshotDisplay; +use super::rate_limits::StatusRateLimitData; +use super::rate_limits::compose_rate_limit_data_many; use crate::history_cell::HistoryCell; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; @@ -13,12 +18,14 @@ use crate::test_support::test_path_buf; use crate::token_usage::TokenUsage; use crate::token_usage::TokenUsageInfo; use chrono::Duration as ChronoDuration; +use chrono::Local; use chrono::TimeZone; use chrono::Utc; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::CreditsSnapshot; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; +use codex_app_server_protocol::SpendControlLimitSnapshot; use codex_config::LoaderOverrides; use codex_model_provider_info::ModelProviderAwsAuthInfo; use codex_model_provider_info::ModelProviderInfo; @@ -40,6 +47,35 @@ use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::prelude::*; use tempfile::TempDir; +use unicode_width::UnicodeWidthStr; + +#[test] +fn stale_monthly_limit_marks_fresh_rolling_snapshot_stale() { + let now = Local::now(); + let snapshot = RateLimitSnapshotDisplay { + limit_name: "codex".to_string(), + captured_at: now, + primary: Some(RateLimitWindowDisplay { + used_percent: 20.0, + resets_at: Some("soon".to_string()), + window_minutes: Some(300), + }), + secondary: None, + credits: None, + individual_limit: Some(SpendControlLimitSnapshotDisplay { + captured_at: now - ChronoDuration::minutes(20), + percent_remaining: 68.0, + used: "8,000".to_string(), + limit: "25,000".to_string(), + resets_at: Some("later".to_string()), + }), + }; + + assert!(matches!( + compose_rate_limit_data_many(&[snapshot], now), + StatusRateLimitData::Stale(_) + )); +} fn app_server_workspace_write_profile(network_enabled: bool) -> PermissionProfile { PermissionProfile::Managed { @@ -133,18 +169,27 @@ fn render_lines(lines: &[Line<'static>]) -> Vec { } fn sanitize_directory(lines: Vec) -> Vec { + let frame_width = lines + .iter() + .find(|line| line.starts_with('╭')) + .map(|line| UnicodeWidthStr::width(line.as_str())); lines .into_iter() .map(|line| { - if let (Some(dir_pos), Some(pipe_idx)) = (line.find("Directory: "), line.rfind('│')) { + if let (Some(frame_width), Some(dir_pos), Some(pipe_idx)) = + (frame_width, line.find("Directory: "), line.rfind('│')) + { let prefix = &line[..dir_pos + "Directory: ".len()]; let suffix = &line[pipe_idx..]; - let content_width = pipe_idx.saturating_sub(dir_pos + "Directory: ".len()); let replacement = "[[workspace]]"; + let content_width = frame_width.saturating_sub( + UnicodeWidthStr::width(prefix) + UnicodeWidthStr::width(suffix), + ); let mut rebuilt = prefix.to_string(); rebuilt.push_str(replacement); - if content_width > replacement.len() { - rebuilt.push_str(&" ".repeat(content_width - replacement.len())); + let replacement_width = UnicodeWidthStr::width(replacement); + if content_width > replacement_width { + rebuilt.push_str(&" ".repeat(content_width - replacement_width)); } rebuilt.push_str(suffix); rebuilt @@ -236,6 +281,7 @@ async fn status_snapshot_includes_reasoning_details() { resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_200)), }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -871,6 +917,7 @@ async fn status_snapshot_includes_monthly_limit() { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -903,6 +950,82 @@ async fn status_snapshot_includes_monthly_limit() { assert_snapshot!(sanitized); } +#[tokio::test] +async fn status_snapshot_includes_enterprise_monthly_credit_limit() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + let account_display = test_status_account_display(); + let usage = TokenUsage { + input_tokens: 800, + cached_input_tokens: 0, + output_tokens: 400, + reasoning_output_tokens: 0, + total_tokens: 1_200, + }; + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 5, 6, 7, 8, 9) + .single() + .expect("timestamp"); + let snapshot = RateLimitSnapshot { + limit_id: None, + limit_name: None, + primary: None, + secondary: None, + credits: None, + individual_limit: Some(SpendControlLimitSnapshot { + limit: "25000".to_string(), + used: "8000".to_string(), + remaining_percent: 68, + resets_at: reset_at_from(&captured_at, /*seconds*/ 86_400), + }), + plan_type: None, + rate_limit_reached_type: None, + }; + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); + let token_info = token_info_for(&model_slug, &config, &usage); + let composite = new_status_output( + &config, + account_display.as_ref(), + Some(&token_info), + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + Some(&rate_display), + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 92)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); + + let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 46)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!( + "status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal", + sanitized + ); +} + #[tokio::test] async fn status_snapshot_uses_generic_limit_labels_for_unsupported_windows() { let temp_home = TempDir::new().expect("temp home"); @@ -938,6 +1061,7 @@ async fn status_snapshot_uses_generic_limit_labels_for_unsupported_windows() { resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 172_800)), }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -990,6 +1114,7 @@ async fn status_snapshot_shows_unlimited_credits() { unlimited: true, balance: None, }), + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1040,6 +1165,7 @@ async fn status_snapshot_shows_positive_credits() { unlimited: false, balance: Some("12.5".to_string()), }), + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1090,6 +1216,7 @@ async fn status_snapshot_hides_zero_credits() { unlimited: false, balance: Some("0".to_string()), }), + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1138,6 +1265,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { unlimited: true, balance: None, }), + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1244,6 +1372,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { }), secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1414,6 +1543,7 @@ async fn status_snapshot_shows_refreshing_limits_notice() { resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_700)), }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1485,6 +1615,7 @@ async fn status_snapshot_includes_credits_and_limits() { unlimited: false, balance: Some("37.5".to_string()), }), + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1539,6 +1670,7 @@ async fn status_snapshot_shows_unavailable_limits_message() { primary: None, secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1596,6 +1728,7 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() { primary: None, secondary: None, credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1667,6 +1800,7 @@ async fn status_snapshot_shows_stale_limits_message() { resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_800)), }), credits: None, + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; @@ -1738,6 +1872,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { unlimited: false, balance: Some("80".to_string()), }), + individual_limit: None, plan_type: None, rate_limit_reached_type: None, }; diff --git a/justfile b/justfile index 2c57e1cdf6..fe7e7349b3 100644 --- a/justfile +++ b/justfile @@ -21,9 +21,9 @@ exec *args: cargo run --bin codex -- exec {args} # Start `codex exec-server` and run codex-tui. -[unix] [no-cd] [positional-arguments] +[unix] tui-with-exec-server *args: {{ justfile_directory() }}/scripts/run_tui_with_exec_server.sh "$@" @@ -36,16 +36,13 @@ app-server-test-client *args: cargo build -p codex-cli cargo run -p codex-app-server-test-client -- --codex-bin ./target/debug/codex {args} -# Format Rust, Python SDK code, and Python scripts. +# Format the justfile, Rust, Python SDK code, and Python scripts. fmt: - cargo fmt -- --config imports_granularity=Item {stderr-null} - uv run --frozen --project ../sdk/python --extra dev ruff check --fix --fix-only ../sdk/python - uv run --frozen --project ../sdk/python --extra dev ruff format ../sdk/python - # Root scripts have their own locked Ruff environment. - uv run --frozen --project ../scripts ruff format ../scripts + {{ python }} ../scripts/format.py -fmt-scripts-check: - uv run --frozen --project ../scripts ruff format --check ../scripts +# Check formatting without modifying files. +fmt-check: + {{ python }} ../scripts/format.py --check fix *args: cargo clippy --fix --tests --allow-dirty {args} @@ -86,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} @@ -97,21 +100,21 @@ bench-smoke: # Build and run Codex from source using Bazel. # On Unix, use `[no-cd]` and `--run_under="cd $PWD &&"` to ensure Bazel runs # the command in the current working directory. -[unix] [no-cd] +[unix] bazel-codex *args: bazel run //codex-rs/cli:codex --run_under="cd $PWD &&" -- "$@" [windows] bazel-codex *args: - bazel run //codex-rs/cli:codex --run_under='cd /d "{{invocation_directory_native()}}" &&' -- @($args | Select-Object -Skip 1) + bazel run //codex-rs/cli:codex --run_under='cd /d "{{ invocation_directory_native() }}" &&' -- @($args | Select-Object -Skip 1) [no-cd] bazel-lock-update: bazel mod deps --lockfile_mode=update -[unix] [no-cd] +[unix] bazel-lock-check: {{ justfile_directory() }}/scripts/check-module-bazel-lock.sh @@ -122,21 +125,18 @@ bazel-lock-check: bazel-test: bazel test --test_tag_filters=-argument-comment-lint //... --keep_going -[unix] [no-cd] +[unix] bazel-clippy: bazel_targets="$({{ justfile_directory() }}/scripts/list-bazel-clippy-targets.sh)" && bazel build --config=clippy -- ${bazel_targets} -[unix] [no-cd] +[unix] 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: @@ -155,8 +155,8 @@ write-hooks-schema: cargo run --manifest-path {{ justfile_directory() }}/codex-rs/Cargo.toml -p codex-hooks --bin write_hooks_schema_fixtures # Run the argument-comment Dylint checks across codex-rs. -[unix] [no-cd] +[unix] argument-comment-lint *args: if [ "$#" -eq 0 ]; then \ bazel build --config=argument-comment-lint -- $({{ justfile_directory() }}/tools/argument-comment-lint/list-bazel-targets.sh); \ diff --git a/scripts/format.py b/scripts/format.py new file mode 100644 index 0000000000..0c64d2aa88 --- /dev/null +++ b/scripts/format.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Format repository sources or check that they are already formatted.""" + +import argparse +import shlex +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +CODEX_RS_ROOT = REPO_ROOT / "codex-rs" + + +@dataclass(frozen=True) +class Command: + args: tuple[str, ...] + cwd: Path = REPO_ROOT + discard_stderr: bool = False + + +@dataclass(frozen=True) +class FormatterGroup: + name: str + commands: tuple[Command, ...] + + +@dataclass(frozen=True) +class FormatterResult: + name: str + output: str + returncode: int + + +def formatter_groups(*, check: bool) -> tuple[FormatterGroup, ...]: + just_args = ["just", "--unstable", "--fmt"] + cargo_args = ["cargo", "fmt", "--", "--config", "imports_granularity=Item"] + # Use an unpinned overlay so Ruff is available without syncing project + # dependencies. Each `--project` still retains its local configuration context. + sdk_uv_run_args = [ + "uv", + "run", + "--frozen", + "--project", + "sdk/python", + "--no-sync", + "--with", + "ruff", + ] + scripts_uv_run_args = [ + "uv", + "run", + "--frozen", + "--project", + "scripts", + "--no-sync", + "--with", + "ruff", + ] + sdk_format_args = [ + *sdk_uv_run_args, + "ruff", + "format", + ] + scripts_format_args = [ + *scripts_uv_run_args, + "ruff", + "format", + ] + + if check: + just_args.append("--check") + cargo_args.append("--check") + sdk_format_args.append("--check") + scripts_format_args.append("--check") + # `ruff check --diff` reports lint-driven rewrites without changing files. + # It is the check-mode counterpart of `--fix --fix-only`, not a full lint gate. + sdk_lint_args = ["ruff", "check", "--diff"] + else: + # Ruff's lint fixer and formatter are separate passes: the first applies + # fixable lint rewrites, while the second formats source layout. + sdk_lint_args = ["ruff", "check", "--fix", "--fix-only"] + + return ( + FormatterGroup("Just", (Command(tuple(just_args)),)), + FormatterGroup( + "Rust", + # Stable rustfmt repeats a nightly-only `imports_granularity` warning + # for each crate, so suppress that expected stderr noise. + (Command(tuple(cargo_args), CODEX_RS_ROOT, discard_stderr=True),), + ), + FormatterGroup( + "Python SDK", + ( + Command( + ( + *sdk_uv_run_args, + *sdk_lint_args, + "sdk/python", + ) + ), + Command((*sdk_format_args, "sdk/python")), + ), + ), + FormatterGroup( + "Python scripts", + ( + # The SDK and internal scripts intentionally use separate project + # roots so uv and Ruff retain each project's configuration context. + Command((*scripts_format_args, "scripts")), + ), + ), + ) + + +def run_formatter_group(group: FormatterGroup) -> FormatterResult: + """Run one formatter group sequentially and return its buffered output.""" + output: list[str] = [] + for command in group.commands: + output.append(f"$ {shlex.join(command.args)}\n") + try: + process = subprocess.run( + command.args, + cwd=command.cwd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL + if command.discard_stderr + else subprocess.STDOUT, + text=True, + check=False, + ) + except OSError as error: + output.append(f"{error}\n") + return FormatterResult(group.name, "".join(output), 1) + + output.append(process.stdout) + if process.stdout and not process.stdout.endswith("\n"): + output.append("\n") + if process.returncode != 0: + return FormatterResult(group.name, "".join(output), process.returncode) + + return FormatterResult(group.name, "".join(output), 0) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--check", + action="store_true", + help="check formatting without modifying files", + ) + args = parser.parse_args() + groups = formatter_groups(check=args.check) + + failures: list[str] = [] + with ThreadPoolExecutor(max_workers=len(groups)) as executor: + futures = {} + for group in groups: + print(f"Starting {group.name} formatter...", flush=True) + futures[executor.submit(run_formatter_group, group)] = group.name + for future in as_completed(futures): + result = future.result() + print(f"==> {result.name} formatter finished") + print(result.output, end="") + if result.returncode != 0: + failures.append(result.name) + + if failures: + print(f"Formatting failed: {', '.join(failures)}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) 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. 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 78fbdd6235..5a8f1275d6 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -14,6 +14,18 @@ import tomllib ROOT = Path(__file__).resolve().parents[1] +def _load_root_format_script_module(): + """Load the root formatter driver so tests exercise its real command graph.""" + script_path = ROOT.parents[1] / "scripts" / "format.py" + spec = importlib.util.spec_from_file_location("format_repo", script_path) + if spec is None or spec.loader is None: + raise AssertionError(f"Failed to load script module: {script_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + def _load_update_script_module(): """Load the maintenance script as a module so tests exercise real helpers.""" script_path = ROOT / "scripts" / "update_sdk_artifacts.py" @@ -68,44 +80,135 @@ def test_generation_has_single_maintenance_entrypoint_script() -> None: assert scripts == ["update_sdk_artifacts.py"] -def test_root_fmt_recipe_formats_rust_python_sdk_and_scripts() -> None: - """The repo fmt command should format Rust, the Python SDK, and scripts.""" +def test_root_fmt_recipes_use_shared_formatter_driver() -> None: + """The root formatting recipes should use the shared cross-platform driver.""" justfile = ROOT.parents[1] / "justfile" lines = justfile.read_text().splitlines() fmt_index = lines.index("fmt:") + fmt_check_index = lines.index("fmt-check:") next_recipe_index = next( index - for index in range(fmt_index + 1, len(lines)) + for index in range(fmt_check_index + 1, len(lines)) if lines[index] and not lines[index].startswith((" ", "\t", "#")) ) - fmt_recipe = lines[fmt_index:next_recipe_index] actual = { "working_directory": lines[0], - "previous_comment": next( - line for line in reversed(lines[:fmt_index]) if line.startswith("#") + "fmt_comment": next(line for line in reversed(lines[:fmt_index]) if line.startswith("#")), + "fmt_commands": [ + line.strip() + for line in lines[fmt_index + 1 : fmt_check_index] + if line.strip() and not line.startswith("#") + ], + "fmt_check_comment": next( + line for line in reversed(lines[:fmt_check_index]) if line.startswith("#") ), - "commands": [line.strip() for line in fmt_recipe[1:] if line.strip()], + "fmt_check_commands": [ + line.strip() for line in lines[fmt_check_index + 1 : next_recipe_index] if line.strip() + ], } expected = { "working_directory": 'set working-directory := "codex-rs"', - "previous_comment": "# Format Rust, Python SDK code, and Python scripts.", - "commands": [ - "cargo fmt -- --config imports_granularity=Item {stderr-null}", - "uv run --frozen --project ../sdk/python --extra dev ruff check --fix --fix-only ../sdk/python", - "uv run --frozen --project ../sdk/python --extra dev ruff format ../sdk/python", - "# Root scripts have their own locked Ruff environment.", - "uv run --frozen --project ../scripts ruff format ../scripts", - ], + "fmt_comment": "# Format the justfile, Rust, Python SDK code, and Python scripts.", + "fmt_commands": ["{{ python }} ../scripts/format.py"], + "fmt_check_comment": "# Check formatting without modifying files.", + "fmt_check_commands": ["{{ python }} ../scripts/format.py --check"], } assert actual == expected, ( - "The root `just fmt` recipe must run Rust fmt and Ruff for Python SDK code and scripts. " - "Fix the `fmt` recipe in `justfile`, then run `just fmt`.\n" + "The root formatting recipes must use the shared formatter driver. " + "Fix the recipes in `justfile`, then run `just fmt`.\n" f"Expected: {json.dumps(expected, indent=2)}\n" f"Actual: {json.dumps(actual, indent=2)}" ) +def test_root_format_driver_covers_all_formatter_groups() -> None: + """The shared driver should retain every formatter in both modes.""" + script = _load_root_format_script_module() + formatters = script.formatter_groups(check=False) + checks = script.formatter_groups(check=True) + + assert [group.name for group in formatters] == [ + "Just", + "Rust", + "Python SDK", + "Python scripts", + ] + assert [group.name for group in checks] == [group.name for group in formatters] + assert [len(group.commands) for group in formatters] == [1, 1, 2, 1] + assert [len(group.commands) for group in checks] == [ + len(group.commands) for group in formatters + ] + sdk_uv_run_args = ( + "uv", + "run", + "--frozen", + "--project", + "sdk/python", + "--no-sync", + "--with", + "ruff", + ) + scripts_uv_run_args = ( + "uv", + "run", + "--frozen", + "--project", + "scripts", + "--no-sync", + "--with", + "ruff", + ) + assert all( + command.args[: len(sdk_uv_run_args)] == sdk_uv_run_args + for group in (formatters[2], checks[2]) + for command in group.commands + ) + assert all( + command.args[: len(scripts_uv_run_args)] == scripts_uv_run_args + for group in (formatters[3], checks[3]) + for command in group.commands + ) + assert formatters[2].commands[0].args[-5:] == ( + "ruff", + "check", + "--fix", + "--fix-only", + "sdk/python", + ) + assert checks[2].commands[0].args[-4:] == ( + "ruff", + "check", + "--diff", + "sdk/python", + ) + assert formatters[0].commands[-1].args == ("just", "--unstable", "--fmt") + assert checks[0].commands[-1].args == ("just", "--unstable", "--fmt", "--check") + assert formatters[1].commands[-1].args == ( + "cargo", + "fmt", + "--", + "--config", + "imports_granularity=Item", + ) + assert checks[1].commands[-1].args == ( + "cargo", + "fmt", + "--", + "--config", + "imports_granularity=Item", + "--check", + ) + assert [group.commands[-1].args[-3:] for group in formatters[2:]] == [ + ("ruff", "format", "sdk/python"), + ("ruff", "format", "scripts"), + ] + assert [group.commands[-1].args[-4:] for group in checks[2:]] == [ + ("ruff", "format", "--check", "sdk/python"), + ("ruff", "format", "--check", "scripts"), + ] + + def test_generate_types_wires_all_generation_steps() -> None: """The type generation command should refresh every schema-derived artifact.""" source = (ROOT / "scripts" / "update_sdk_artifacts.py").read_text() @@ -162,8 +265,9 @@ def test_schema_normalization_only_flattens_string_literal_oneofs( "AuthMode", "InputModality", "ExperimentalFeatureStage", - "ProcessOutputStream", + "AutoCompactTokenLimitScope", "CommandExecOutputStream", + "ProcessOutputStream", ] @@ -254,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", ], } @@ -332,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", } @@ -491,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: @@ -547,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 ( @@ -584,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, ) @@ -597,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", ], } @@ -659,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", ] ) @@ -690,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]]