diff --git a/.github/dotslash-config.json b/.github/dotslash-config.json index 78ea6b37e6fe..a0297c269a87 100644 --- a/.github/dotslash-config.json +++ b/.github/dotslash-config.json @@ -3,56 +3,56 @@ "codex": { "platforms": { "macos-aarch64": { - "regex": "^codex-package-aarch64-apple-darwin\\.tar\\.zst$", - "path": "bin/codex" + "regex": "^codex-aarch64-apple-darwin\\.zst$", + "path": "codex" }, "macos-x86_64": { - "regex": "^codex-package-x86_64-apple-darwin\\.tar\\.zst$", - "path": "bin/codex" + "regex": "^codex-x86_64-apple-darwin\\.zst$", + "path": "codex" }, "linux-x86_64": { - "regex": "^codex-package-x86_64-unknown-linux-musl\\.tar\\.zst$", - "path": "bin/codex" + "regex": "^codex-x86_64-unknown-linux-musl-bundle\\.tar\\.zst$", + "path": "codex" }, "linux-aarch64": { - "regex": "^codex-package-aarch64-unknown-linux-musl\\.tar\\.zst$", - "path": "bin/codex" + "regex": "^codex-aarch64-unknown-linux-musl-bundle\\.tar\\.zst$", + "path": "codex" }, "windows-x86_64": { - "regex": "^codex-package-x86_64-pc-windows-msvc\\.tar\\.zst$", - "path": "bin/codex.exe" + "regex": "^codex-x86_64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex.exe" }, "windows-aarch64": { - "regex": "^codex-package-aarch64-pc-windows-msvc\\.tar\\.zst$", - "path": "bin/codex.exe" + "regex": "^codex-aarch64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex.exe" } } }, "codex-app-server": { "platforms": { "macos-aarch64": { - "regex": "^codex-app-server-package-aarch64-apple-darwin\\.tar\\.zst$", - "path": "bin/codex-app-server" + "regex": "^codex-app-server-aarch64-apple-darwin\\.zst$", + "path": "codex-app-server" }, "macos-x86_64": { - "regex": "^codex-app-server-package-x86_64-apple-darwin\\.tar\\.zst$", - "path": "bin/codex-app-server" + "regex": "^codex-app-server-x86_64-apple-darwin\\.zst$", + "path": "codex-app-server" }, "linux-x86_64": { - "regex": "^codex-app-server-package-x86_64-unknown-linux-musl\\.tar\\.zst$", - "path": "bin/codex-app-server" + "regex": "^codex-app-server-x86_64-unknown-linux-musl\\.zst$", + "path": "codex-app-server" }, "linux-aarch64": { - "regex": "^codex-app-server-package-aarch64-unknown-linux-musl\\.tar\\.zst$", - "path": "bin/codex-app-server" + "regex": "^codex-app-server-aarch64-unknown-linux-musl\\.zst$", + "path": "codex-app-server" }, "windows-x86_64": { - "regex": "^codex-app-server-package-x86_64-pc-windows-msvc\\.tar\\.zst$", - "path": "bin/codex-app-server.exe" + "regex": "^codex-app-server-x86_64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-app-server.exe" }, "windows-aarch64": { - "regex": "^codex-app-server-package-aarch64-pc-windows-msvc\\.tar\\.zst$", - "path": "bin/codex-app-server.exe" + "regex": "^codex-app-server-aarch64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-app-server.exe" } } }, diff --git a/.github/scripts/build-codex-package-archive.sh b/.github/scripts/build-codex-package-archive.sh index 80da4cf20c91..90eae12ef074 100644 --- a/.github/scripts/build-codex-package-archive.sh +++ b/.github/scripts/build-codex-package-archive.sh @@ -8,9 +8,6 @@ Usage: build-codex-package-archive.sh \ --bundle \ --entrypoint-dir \ --archive-dir \ - [--bwrap-bin ] \ - [--codex-command-runner-bin ] \ - [--codex-windows-sandbox-setup-bin ] \ [--target-suffixed-entrypoint] EOF } @@ -20,10 +17,6 @@ bundle="" entrypoint_dir="" archive_dir="" target_suffixed_entrypoint="false" -resource_args=() -bwrap_bin_provided="false" -command_runner_bin_provided="false" -sandbox_setup_bin_provided="false" while [[ $# -gt 0 ]]; do case "$1" in @@ -43,27 +36,6 @@ while [[ $# -gt 0 ]]; do archive_dir="${2:?--archive-dir requires a value}" shift 2 ;; - --bwrap-bin) - resource_args+=(--bwrap-bin "${2:?--bwrap-bin requires a value}") - bwrap_bin_provided="true" - shift 2 - ;; - --codex-command-runner-bin) - resource_args+=( - --codex-command-runner-bin - "${2:?--codex-command-runner-bin requires a value}" - ) - command_runner_bin_provided="true" - shift 2 - ;; - --codex-windows-sandbox-setup-bin) - resource_args+=( - --codex-windows-sandbox-setup-bin - "${2:?--codex-windows-sandbox-setup-bin requires a value}" - ) - sandbox_setup_bin_provided="true" - shift 2 - ;; --target-suffixed-entrypoint) target_suffixed_entrypoint="true" shift @@ -114,25 +86,6 @@ if [[ "$target_suffixed_entrypoint" == "true" ]]; then entrypoint_name="${entrypoint_name}-${target}" fi -case "$target" in - *linux*) - bwrap_bin="${entrypoint_dir%/}/bwrap" - if [[ "$bwrap_bin_provided" == "false" && -f "$bwrap_bin" ]]; then - resource_args+=(--bwrap-bin "$bwrap_bin") - fi - ;; - *windows*) - command_runner_bin="${entrypoint_dir%/}/codex-command-runner.exe" - sandbox_setup_bin="${entrypoint_dir%/}/codex-windows-sandbox-setup.exe" - if [[ "$command_runner_bin_provided" == "false" && -f "$command_runner_bin" ]]; then - resource_args+=(--codex-command-runner-bin "$command_runner_bin") - fi - if [[ "$sandbox_setup_bin_provided" == "false" && -f "$sandbox_setup_bin" ]]; then - resource_args+=(--codex-windows-sandbox-setup-bin "$sandbox_setup_bin") - fi - ;; -esac - repo_root="${GITHUB_WORKSPACE:-}" if [[ -z "$repo_root" ]]; then repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -144,29 +97,16 @@ else python_bin="python" fi -if ! command -v zstd >/dev/null 2>&1 && [[ -x "${repo_root}/.github/workflows/zstd" ]]; then - export PATH="${repo_root}/.github/workflows:${PATH}" -fi - mkdir -p "$archive_dir" package_dir="${RUNNER_TEMP:-/tmp}/${archive_stem}-${target}" -gzip_archive_path="${archive_dir}/${archive_stem}-${target}.tar.gz" -zstd_archive_path="${archive_dir}/${archive_stem}-${target}.tar.zst" +archive_path="${archive_dir}/${archive_stem}-${target}.tar.gz" rm -rf "$package_dir" -python_args=( - "${repo_root}/scripts/build_codex_package.py" - --target "$target" - --variant "$variant" - --entrypoint-bin "${entrypoint_dir%/}/${entrypoint_name}${exe_suffix}" - --cargo-profile release - --package-dir "$package_dir" - --archive-output "$gzip_archive_path" - --archive-output "$zstd_archive_path" -) -if ((${#resource_args[@]} > 0)); then - python_args+=("${resource_args[@]}") -fi -python_args+=(--force) - -"$python_bin" "${python_args[@]}" +"$python_bin" "${repo_root}/scripts/build_codex_package.py" \ + --target "$target" \ + --variant "$variant" \ + --entrypoint-bin "${entrypoint_dir%/}/${entrypoint_name}${exe_suffix}" \ + --cargo-profile release \ + --package-dir "$package_dir" \ + --archive-output "$archive_path" \ + --force diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25dff134a77d..a1c60acc26d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,9 +26,6 @@ jobs: - name: Verify Bazel clippy flags match Cargo workspace lints run: python3 .github/scripts/verify_bazel_clippy_lints.py - - name: Test Codex package builder - run: python3 -m unittest discover -s scripts/codex_package -p 'test_*.py' - - name: Setup pnpm uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: @@ -56,16 +53,14 @@ jobs: # Passing the workflow URL directly avoids relying on old rust-v* # branches remaining discoverable via `gh run list --branch ...`. CODEX_VERSION=0.125.0 - WORKFLOW_URL="https://github.com/openai/codex/actions/runs/26131514935" + WORKFLOW_URL="https://github.com/openai/codex/actions/runs/24901475298" OUTPUT_DIR="${RUNNER_TEMP}" - # This reused workflow predates codex-package archive artifacts, so - # CI synthesizes the package layout from the older per-binary - # artifacts. Release staging must use real package archives. + # This reused workflow predates the standalone bwrap artifact. python3 ./scripts/stage_npm_packages.py \ --release-version "$CODEX_VERSION" \ --workflow-url "$WORKFLOW_URL" \ --package codex \ - --allow-legacy-codex-package \ + --allow-missing-native-component bwrap \ --output-dir "$OUTPUT_DIR" PACK_OUTPUT="${OUTPUT_DIR}/codex-npm-${CODEX_VERSION}.tgz" echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 51412be0e02a..ac28b7855a17 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -220,9 +220,6 @@ jobs: "$dest/${binary}-${{ matrix.target }}.exe" done - - name: Install DotSlash - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - - name: Build Codex package archives shell: bash run: | @@ -258,12 +255,16 @@ jobs: stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" + # Keep the helpers next to codex.exe in the runtime wheel so Windows + # sandbox/elevation lookup matches the standalone release zip. python "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ stage-runtime \ "$stage_dir" \ - "dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" \ + "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex.exe" \ --codex-version "${GITHUB_REF_NAME}" \ - --platform-tag "$platform_tag" + --platform-tag "$platform_tag" \ + --resource-binary "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex-command-runner.exe" \ + --resource-binary "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe" "${RUNNER_TEMP}/python-runtime-build-venv/Scripts/python.exe" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - name: Upload Python runtime wheel @@ -273,6 +274,9 @@ jobs: path: python-runtime-dist/${{ matrix.target }}/*.whl if-no-files-found: error + - name: Install DotSlash + uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + - name: Compress artifacts shell: bash run: | @@ -291,7 +295,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 4f10efa9dcf5..c55337ecfe6e 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -180,25 +180,25 @@ jobs: binaries: "codex-app-server" build_dmg: "false" # Release artifacts intentionally ship MUSL-linked Linux binaries. - - runner: codex-linux-x64-xl + - runner: ubuntu-24.04 target: x86_64-unknown-linux-musl bundle: primary artifact_name: x86_64-unknown-linux-musl binaries: "codex codex-responses-api-proxy bwrap" build_dmg: "false" - - runner: codex-linux-x64-xl + - runner: ubuntu-24.04 target: x86_64-unknown-linux-musl bundle: app-server artifact_name: x86_64-unknown-linux-musl-app-server binaries: "codex-app-server" build_dmg: "false" - - runner: codex-linux-arm64 + - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-musl bundle: primary artifact_name: aarch64-unknown-linux-musl binaries: "codex codex-responses-api-proxy bwrap" build_dmg: "false" - - runner: codex-linux-arm64 + - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-musl bundle: app-server artifact_name: aarch64-unknown-linux-musl-app-server @@ -346,7 +346,7 @@ jobs: with: target: ${{ matrix.target }} - - if: ${{ contains(matrix.target, 'linux') }} + - if: ${{ contains(matrix.target, 'linux') && matrix.bundle == 'primary' }} name: Build bwrap and export digest shell: bash run: | @@ -569,10 +569,18 @@ jobs: "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" stage-runtime "$stage_dir" - "dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" + "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex" --codex-version "${GITHUB_REF_NAME}" --platform-tag "$platform_tag" ) + if [[ "${{ matrix.target }}" == *linux* ]]; then + # Keep bwrap in the runtime wheel so Linux sandbox fallback behavior + # matches the standalone release bundle on hosts without system bwrap. + stage_runtime_args+=( + --resource-binary + "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/bwrap" + ) + fi python3 "${stage_runtime_args[@]}" "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" @@ -792,20 +800,6 @@ jobs: cp "$dmg_source" "$dest/$dmg_name" fi - - name: Build Codex package archive - shell: bash - env: - TARGET: ${{ matrix.target }} - BUNDLE: ${{ matrix.bundle }} - run: | - set -euo pipefail - bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ - --target "$TARGET" \ - --bundle "$BUNDLE" \ - --entrypoint-dir "dist/${TARGET}" \ - --archive-dir "dist/${TARGET}" \ - --target-suffixed-entrypoint - - name: Build Python runtime wheel if: ${{ matrix.bundle == 'primary' }} shell: bash @@ -834,11 +828,25 @@ jobs: "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ stage-runtime \ "$stage_dir" \ - "dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" \ + "${GITHUB_WORKSPACE}/codex-rs/dist/${{ matrix.target }}/codex-${{ matrix.target }}" \ --codex-version "${GITHUB_REF_NAME}" \ --platform-tag "$platform_tag" "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" + - name: Build Codex package archive + shell: bash + env: + TARGET: ${{ matrix.target }} + BUNDLE: ${{ matrix.bundle }} + run: | + set -euo pipefail + bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ + --target "$TARGET" \ + --bundle "$BUNDLE" \ + --entrypoint-dir "dist/${TARGET}" \ + --archive-dir "dist/${TARGET}" \ + --target-suffixed-entrypoint + - name: Upload Python runtime wheel if: ${{ matrix.bundle == 'primary' }} uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 @@ -1099,33 +1107,15 @@ jobs: # If included in files: dist/**, release upload races on duplicate # asset names and can fail with 404s. find dist -type f -name 'cargo-timing.html' -delete + # Keep package-builder sidecar archives as workflow artifacts only + # until distribution channels are ready to consume them. + find dist -type f \ + \( -name 'codex-package-*' -o -name 'codex-app-server-package-*' \) \ + -delete find dist -type d -empty -delete ls -R dist/ - - name: Add Codex package checksum manifest - run: | - set -euo pipefail - - manifest="dist/codex-package_SHA256SUMS" - tmp_manifest="$(mktemp)" - find dist -type f \ - \( -name 'codex-package-*.tar.gz' -o -name 'codex-app-server-package-*.tar.gz' \) \ - -print | - sort | - while IFS= read -r archive; do - sha256sum "$archive" | - awk -v name="$(basename "$archive")" '{ print $1 " " name }' - done > "$tmp_manifest" - - if [[ ! -s "$tmp_manifest" ]]; then - echo "No Codex package archives found for checksum manifest" - exit 1 - fi - - mv "$tmp_manifest" "$manifest" - cat "$manifest" - - name: Add config schema release asset run: | cp codex-rs/core/config.schema.json dist/config-schema.json @@ -1200,6 +1190,8 @@ jobs: if: ${{ env.SIGN_MACOS == 'true' }} run: pnpm install --frozen-lockfile + # stage_npm_packages.py requires DotSlash when staging releases. + - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - name: Stage npm packages if: ${{ env.SIGN_MACOS == 'true' }} env: diff --git a/AGENTS.md b/AGENTS.md index c13fdea641a8..4714b1b8aa43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ In the codex-rs folder where the rust code lives: - Prefer private modules and explicitly exported public crate API. - If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`. - When working with MCP tool calls, prefer using `codex-rs/codex-mcp/src/mcp_connection_manager.rs` to handle mutation of tools and tool calls. Aim to minimize the footprint of changes and leverage existing abstractions rather than plumbing code through multiple levels of function calls. +- Do not call `reset_client_session` unnecessarily; let the incremental check logic decide whether to reuse the previous request. - If you change Rust dependencies (`Cargo.toml` or `Cargo.lock`), run `just bazel-lock-update` from the repo root to refresh `MODULE.bazel.lock`, and include that lockfile update in the same change. - After dependency changes, run `just bazel-lock-check` from the repo root so lockfile drift is caught @@ -52,12 +53,13 @@ In the codex-rs folder where the rust code lives: the new implementation so the invariants stay close to the code that owns them. - Avoid adding new standalone methods to `codex-rs/tui/src/chatwidget.rs` unless the change is trivial; prefer new modules/files and keep `chatwidget.rs` focused on orchestration. -- When running Rust commands (e.g. `just fix` or `cargo test`) be patient with the command and never try to kill them using the PID. Rust lock can make the execution slow, this is expected. +- When running Rust commands (e.g. `just fix` or `just test`) be patient with the command and never try to kill them using the PID. Rust lock can make the execution slow, this is expected. Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests: -1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`. -2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test` (or `just test` if `cargo-nextest` is installed). Avoid `--all-features` for routine local runs because it expands the build matrix and can significantly increase `target/` disk usage; use it only when you specifically need full feature coverage. project-specific or individual tests can be run without asking the user, but do ask the user before running the complete test suite. +1. Do not run `cargo test` directly. Use `just test` so test execution follows the repo defaults. +2. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `just test -p codex-tui`. +3. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `just test`. Avoid `--all-features` for routine local runs because it expands the build matrix and can significantly increase `target/` disk usage; use it only when you specifically need full feature coverage. project-specific or individual tests can be run without asking the user, but do ask the user before running the complete test suite. Before finalizing a large change to `codex-rs`, run `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Do not re-run tests after running `fix` or `fmt`. @@ -120,7 +122,7 @@ is easy to review and future diffs stay visual. When UI or text output changes intentionally, update the snapshots as follows: - Run tests to generate any updated snapshots: - - `cargo test -p codex-tui` + - `just test -p codex-tui` - Check what’s pending: - `cargo insta pending-snapshots -p codex-tui` - Review changes by reading the generated `*.snap.new` files directly in the repo, or preview a specific file: @@ -214,6 +216,6 @@ These guidelines apply to app-server protocol work in `codex-rs`, especially: - Regenerate schema fixtures when API shapes change: `just write-app-server-schema` (and `just write-app-server-schema --experimental` when experimental API fixtures are affected). -- Validate with `cargo test -p codex-app-server-protocol`. +- Validate with `just test -p codex-app-server-protocol`. - Avoid boilerplate tests that only assert experimental field markers for individual request fields in `common.rs`; rely on schema generation/tests and behavioral coverage instead. diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 02d7c76a9111..ad56196dd9db 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -768,6 +768,7 @@ "compact_str_0.8.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"borsh\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"castaway\",\"req\":\"^0.2.3\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"diesel\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"markup\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"proptest\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"size_32\"],\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"size_32\"],\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.7\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"name\":\"ryu\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"union\"],\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"sqlx\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"static_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"test-case\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"test-strategy\",\"req\":\"^0.3\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"borsh\":[\"dep:borsh\"],\"bytes\":[\"dep:bytes\"],\"default\":[\"std\"],\"diesel\":[\"dep:diesel\"],\"markup\":[\"dep:markup\"],\"proptest\":[\"dep:proptest\"],\"quickcheck\":[\"dep:quickcheck\"],\"rkyv\":[\"dep:rkyv\"],\"serde\":[\"dep:serde\"],\"smallvec\":[\"dep:smallvec\"],\"sqlx\":[\"dep:sqlx\",\"std\"],\"sqlx-mysql\":[\"sqlx\",\"sqlx/mysql\"],\"sqlx-postgres\":[\"sqlx\",\"sqlx/postgres\"],\"sqlx-sqlite\":[\"sqlx\",\"sqlx/sqlite\"],\"std\":[]}}", "compiletest_rs_0.11.2": "{\"dependencies\":[{\"name\":\"diff\",\"req\":\"^0.1.10\"},{\"name\":\"filetime\",\"req\":\"^0.2\"},{\"name\":\"getopts\",\"req\":\"^0.2\"},{\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"miow\",\"req\":\"^0.6\",\"target\":\"cfg(windows)\"},{\"name\":\"regex\",\"req\":\"^1.0\"},{\"name\":\"rustfix\",\"req\":\"^0.8\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"tempfile\",\"optional\":true,\"req\":\"^3.0\"},{\"name\":\"tester\",\"req\":\"^0.9\"},{\"features\":[\"Win32\"],\"name\":\"windows-sys\",\"req\":\"^0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"rustc\":[],\"stable\":[],\"tmp\":[\"tempfile\"]}}", "concurrent-queue_2.5.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.11\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", + "condtype_1.3.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.141\"}],\"features\":{}}", "console_0.15.11": "{\"dependencies\":[{\"name\":\"encode_unicode\",\"req\":\"^1\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.99\"},{\"name\":\"once_cell\",\"req\":\"^1.8\"},{\"default_features\":false,\"features\":[\"std\",\"bit-set\",\"break-dead-code\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.4.2\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\",\"Win32_Storage_FileSystem\",\"Win32_UI_Input_KeyboardAndMouse\"],\"name\":\"windows-sys\",\"req\":\"^0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"ansi-parsing\":[],\"default\":[\"unicode-width\",\"ansi-parsing\"],\"windows-console-colors\":[\"ansi-parsing\"]}}", "const-hex_1.17.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"kind\":\"dev\",\"name\":\"divan\",\"package\":\"codspeed-divan-compat\",\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"faster-hex\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"~0.4.2\"},{\"default_features\":false,\"name\":\"proptest\",\"optional\":true,\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"rustc-hex\",\"req\":\"^2.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"__fuzzing\":[\"dep:proptest\",\"std\"],\"alloc\":[\"serde_core?/alloc\",\"proptest?/alloc\"],\"core-error\":[],\"default\":[\"std\"],\"force-generic\":[],\"hex\":[],\"nightly\":[],\"portable-simd\":[],\"serde\":[\"dep:serde_core\"],\"std\":[\"serde_core?/std\",\"proptest?/std\",\"alloc\"]}}", "const-oid_0.9.6": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"}],\"features\":{\"db\":[],\"std\":[]}}", @@ -859,6 +860,8 @@ "dispatch2_0.3.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bitflags\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"block2\",\"optional\":true,\"req\":\">=0.6.1, <0.8.0\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.80\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"objc2\",\"optional\":true,\"req\":\">=0.6.1, <0.8.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"alloc\":[],\"block2\":[\"dep:block2\"],\"default\":[\"std\",\"block2\",\"libc\",\"objc2\"],\"libc\":[\"dep:libc\"],\"objc2\":[\"dep:objc2\"],\"std\":[\"alloc\"]}}", "display_container_0.9.0": "{\"dependencies\":[{\"name\":\"either\",\"req\":\"^1.8\"},{\"name\":\"indenter\",\"req\":\"^0.3.3\"}],\"features\":{}}", "displaydoc_0.2.5": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1.0.24\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", + "divan-macros_0.1.21": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"full\",\"clone-impls\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.18\"}],\"features\":{}}", + "divan_0.1.21": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\",\"env\"],\"name\":\"clap\",\"req\":\"^4\"},{\"name\":\"condtype\",\"req\":\"^1.3\"},{\"name\":\"divan-macros\",\"req\":\"=0.1.21\"},{\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"mimalloc\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"std\",\"string\"],\"name\":\"regex\",\"package\":\"regex-lite\",\"req\":\"^0.1\"}],\"features\":{\"default\":[\"wrap_help\"],\"dyn_thread_local\":[],\"help\":[\"clap/help\"],\"internal_benches\":[],\"wrap_help\":[\"help\",\"clap/wrap_help\"]}}", "dns-lookup_3.0.1": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"socket2\",\"req\":\"^0.6.0\"},{\"features\":[\"Win32_Networking_WinSock\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\"^0.60\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "document-features_0.2.12": "{\"dependencies\":[{\"name\":\"litrs\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[],\"self-test\":[]}}", "dotenvy_0.15.7": "{\"dependencies\":[{\"name\":\"clap\",\"optional\":true,\"req\":\"^3.2\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.16.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.3.0\"}],\"features\":{\"cli\":[\"clap\"]}}", @@ -1150,6 +1153,7 @@ "jni_0.21.1": "{\"dependencies\":[{\"name\":\"cesu8\",\"req\":\"^1.1.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"combine\",\"req\":\"^4.1.0\"},{\"name\":\"java-locator\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"jni-sys\",\"req\":\"^0.3.0\"},{\"name\":\"libloading\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"name\":\"thiserror\",\"req\":\"^1.0.20\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rusty-fork\",\"req\":\"^0.3.0\"},{\"kind\":\"build\",\"name\":\"walkdir\",\"req\":\"^2\"},{\"features\":[\"Win32_Globalization\"],\"name\":\"windows-sys\",\"req\":\"^0.45.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.13.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"invocation\":[\"java-locator\",\"libloading\"]}}", "jobserver_0.1.34": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"getrandom\",\"req\":\"^0.3.2\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.171\",\"target\":\"cfg(unix)\"},{\"features\":[\"fs\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.28.0\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"}],\"features\":{}}", "js-sys_0.3.85": "{\"dependencies\":[{\"default_features\":false,\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"=0.2.108\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"wasm-bindgen/std\"]}}", + "jsonptr_0.7.1": "{\"dependencies\":[{\"features\":[\"fancy\"],\"name\":\"miette\",\"optional\":true,\"req\":\"^7.4.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.203\"},{\"features\":[\"alloc\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.119\"},{\"name\":\"syn\",\"optional\":true,\"req\":\"^1.0.109\",\"target\":\"cfg(any())\"},{\"name\":\"toml\",\"optional\":true,\"req\":\"^0.8\"}],\"features\":{\"assign\":[],\"default\":[\"std\",\"serde\",\"json\",\"resolve\",\"assign\",\"delete\"],\"delete\":[\"resolve\"],\"json\":[\"dep:serde_json\",\"serde\"],\"miette\":[\"dep:miette\",\"std\"],\"resolve\":[],\"std\":[\"serde/std\",\"serde_json?/std\"],\"toml\":[\"dep:toml\",\"serde\",\"std\"]}}", "jsonwebtoken_9.3.1": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\")))))\"},{\"name\":\"js-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"pem\",\"optional\":true,\"req\":\"^3\"},{\"features\":[\"std\"],\"name\":\"ring\",\"req\":\"^0.17.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\",\"wasm32_unknown_unknown_js\"],\"name\":\"ring\",\"req\":\"^0.17.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"simple_asn1\",\"optional\":true,\"req\":\"^0.6\"},{\"features\":[\"wasm-bindgen\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\")))))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.1\"}],\"features\":{\"default\":[\"use_pem\"],\"use_pem\":[\"pem\",\"simple_asn1\"]}}", "keyring_3.6.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"byteorder\",\"optional\":true,\"req\":\"^1.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"features\":[\"derive\",\"wrap_help\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.0-rc.1\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.0-rc.2\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11.5\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2\"},{\"features\":[\"std\"],\"name\":\"linux-keyutils\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"log\",\"req\":\"^0.4.22\"},{\"name\":\"openssl\",\"optional\":true,\"req\":\"^0.10.66\"},{\"kind\":\"dev\",\"name\":\"rpassword\",\"req\":\"^7\"},{\"kind\":\"dev\",\"name\":\"rprompt\",\"req\":\"^2\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"security-framework\",\"optional\":true,\"req\":\"^2\",\"target\":\"cfg(target_os = \\\"ios\\\")\"},{\"name\":\"security-framework\",\"optional\":true,\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"whoami\",\"req\":\"^1.5\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Credentials\"],\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.60\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"zeroize\",\"req\":\"^1.8.1\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"apple-native\":[\"dep:security-framework\"],\"async-io\":[\"zbus?/async-io\"],\"async-secret-service\":[\"dep:secret-service\",\"dep:zbus\"],\"crypto-openssl\":[\"dbus-secret-service?/crypto-openssl\",\"secret-service?/crypto-openssl\"],\"crypto-rust\":[\"dbus-secret-service?/crypto-rust\",\"secret-service?/crypto-rust\"],\"linux-native\":[\"dep:linux-keyutils\"],\"linux-native-async-persistent\":[\"linux-native\",\"async-secret-service\"],\"linux-native-sync-persistent\":[\"linux-native\",\"sync-secret-service\"],\"sync-secret-service\":[\"dep:dbus-secret-service\"],\"tokio\":[\"zbus?/tokio\"],\"vendored\":[\"dbus-secret-service?/vendored\",\"openssl?/vendored\"],\"windows-native\":[\"dep:windows-sys\",\"dep:byteorder\"]}}", "kqueue-sys_1.0.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^1.2.1\"},{\"name\":\"libc\",\"req\":\"^0.2.74\"}],\"features\":{}}", diff --git a/README.md b/README.md index 5cc7fd4953cb..77c8d2199cac 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -

npm i -g @openai/codex
or brew install --cask codex

Codex CLI is a coding agent from OpenAI that runs locally on your computer.

Codex CLI splash @@ -14,7 +13,19 @@ If you want Codex in your code editor (VS Code, Cursor, Windsurf), argparse.Namespace: parser = argparse.ArgumentParser(description="Build or stage the Codex CLI npm package.") parser.add_argument( @@ -140,16 +130,6 @@ def parse_args() -> argparse.Namespace: type=Path, help="Directory containing pre-installed native binaries to bundle (vendor root).", ) - parser.add_argument( - "--allow-missing-native-component", - dest="allow_missing_native_components", - action="append", - default=[], - help=( - "Native component that may be absent from --vendor-src. Intended for CI " - "compatibility with older artifact workflows; releases should not use this." - ), - ) return parser.parse_args() @@ -190,7 +170,6 @@ def main() -> int: staging_dir, native_components, target_filter={target_filter} if target_filter else None, - allow_missing_components=set(args.allow_missing_native_components), ) if release_version: @@ -346,7 +325,7 @@ def compute_platform_package_version(version: str, platform_tag: str) -> str: def run_command(cmd: list[str], cwd: Path | None = None) -> None: - print("+", " ".join(cmd)) + print("+", " ".join(cmd), flush=True) subprocess.run(cmd, cwd=cwd, check=True) @@ -376,18 +355,12 @@ def copy_native_binaries( staging_dir: Path, components: list[str], target_filter: set[str] | None = None, - allow_missing_components: set[str] | None = None, ) -> None: vendor_src = vendor_src.resolve() if not vendor_src.exists(): raise RuntimeError(f"Vendor source directory not found: {vendor_src}") - components_set = { - component - for component in components - if component == CODEX_PACKAGE_COMPONENT or component in COMPONENT_DEST_DIR - } - allow_missing_components = allow_missing_components or set() + components_set = set(components) if not components_set: return @@ -410,34 +383,20 @@ def copy_native_binaries( dest_target_dir = vendor_dest / target_dir.name if CODEX_PACKAGE_COMPONENT in components_set: - validate_codex_package_dir(target_dir) if dest_target_dir.exists(): shutil.rmtree(dest_target_dir) - dest_target_dir.mkdir(parents=True, exist_ok=True) - for entry in CODEX_PACKAGE_ENTRIES: - src = target_dir / entry - dest = dest_target_dir / entry - if src.is_dir(): - shutil.copytree(src, dest) - else: - shutil.copy2(src, dest) + shutil.copytree(target_dir, dest_target_dir) else: dest_target_dir.mkdir(parents=True, exist_ok=True) - for component in components_set - {CODEX_PACKAGE_COMPONENT}: - dest_dir_name = COMPONENT_DEST_DIR.get(component) - if dest_dir_name is None: - continue - - src_component_dir = target_dir / dest_dir_name + for component in sorted(components_set - {CODEX_PACKAGE_COMPONENT}): + src_component_dir = target_dir / component if not src_component_dir.exists(): - if component in allow_missing_components: - continue raise RuntimeError( f"Missing native component '{component}' in vendor source: {src_component_dir}" ) - dest_component_dir = dest_target_dir / dest_dir_name + dest_component_dir = dest_target_dir / component if dest_component_dir.exists(): shutil.rmtree(dest_component_dir) shutil.copytree(src_component_dir, dest_component_dir) @@ -448,45 +407,23 @@ def copy_native_binaries( missing_list = ", ".join(missing_targets) raise RuntimeError(f"Missing target directories in vendor source: {missing_list}") - -def validate_codex_package_dir(package_dir: Path) -> None: - is_windows = "windows" in package_dir.name - required_files = [ - Path("codex-package.json"), - Path("bin") / ("codex.exe" if is_windows else "codex"), - Path("codex-path") / ("rg.exe" if is_windows else "rg"), - ] - - if "linux" in package_dir.name: - required_files.append(Path("codex-resources") / "bwrap") - - if is_windows: - required_files.extend( - [ - Path("codex-resources") / "codex-command-runner.exe", - Path("codex-resources") / "codex-windows-sandbox-setup.exe", - ] - ) - - missing_files = [ - str(relative_path) - for relative_path in required_files - if not (package_dir / relative_path).is_file() - ] - if missing_files: - missing = ", ".join(missing_files) - raise RuntimeError(f"Missing files in Codex package directory {package_dir}: {missing}") - - def run_npm_pack(staging_dir: Path, output_path: Path) -> Path: output_path = output_path.resolve() output_path.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="codex-npm-pack-") as pack_dir_str: pack_dir = Path(pack_dir_str) + npm_cache_dir = pack_dir / "npm-cache" + npm_logs_dir = pack_dir / "npm-logs" + npm_cache_dir.mkdir() + npm_logs_dir.mkdir() + env = os.environ.copy() + env["NPM_CONFIG_CACHE"] = str(npm_cache_dir) + env["NPM_CONFIG_LOGS_DIR"] = str(npm_logs_dir) stdout = subprocess.check_output( ["npm", "pack", "--json", "--pack-destination", str(pack_dir)], cwd=staging_dir, + env=env, text=True, ) try: diff --git a/codex-cli/scripts/install_native_deps.py b/codex-cli/scripts/install_native_deps.py deleted file mode 100755 index de157334cd07..000000000000 --- a/codex-cli/scripts/install_native_deps.py +++ /dev/null @@ -1,654 +0,0 @@ -#!/usr/bin/env python3 -"""Install Codex package archives and native helper binaries.""" - -import argparse -from contextlib import contextmanager -import json -import os -import shutil -import subprocess -import tarfile -import tempfile -import zipfile -from dataclasses import dataclass -from concurrent.futures import ThreadPoolExecutor, as_completed -from pathlib import Path -import sys -from typing import Iterable, Sequence -from urllib.parse import urlparse -from urllib.request import urlopen - -SCRIPT_DIR = Path(__file__).resolve().parent -CODEX_CLI_ROOT = SCRIPT_DIR.parent -REPO_ROOT = CODEX_CLI_ROOT.parent -DEFAULT_WORKFLOW_URL = "https://github.com/openai/codex/actions/runs/26131514935" # rust-v0.132.0 -VENDOR_DIR_NAME = "vendor" -RG_MANIFEST = REPO_ROOT / "scripts" / "codex_package" / "rg" -BINARY_TARGETS = ( - "x86_64-unknown-linux-musl", - "aarch64-unknown-linux-musl", - "x86_64-apple-darwin", - "aarch64-apple-darwin", - "x86_64-pc-windows-msvc", - "aarch64-pc-windows-msvc", -) -CODEX_PACKAGE_COMPONENT = "codex-package" - - -@dataclass(frozen=True) -class BinaryComponent: - artifact_prefix: str # matches the artifact filename prefix (e.g. codex-.zst) - dest_dir: str # directory under vendor// where the binary is installed - binary_basename: str # executable name inside dest_dir (before optional .exe) - targets: tuple[str, ...] | None = None # limit installation to specific targets - - -WINDOWS_TARGETS = tuple(target for target in BINARY_TARGETS if "windows" in target) -LINUX_TARGETS = tuple(target for target in BINARY_TARGETS if "linux" in target) - -BINARY_COMPONENTS = { - "bwrap": BinaryComponent( - artifact_prefix="bwrap", - dest_dir="codex-resources", - binary_basename="bwrap", - targets=LINUX_TARGETS, - ), - "codex": BinaryComponent( - artifact_prefix="codex", - dest_dir="codex", - binary_basename="codex", - ), - "codex-responses-api-proxy": BinaryComponent( - artifact_prefix="codex-responses-api-proxy", - dest_dir="codex-responses-api-proxy", - binary_basename="codex-responses-api-proxy", - ), - "codex-windows-sandbox-setup": BinaryComponent( - artifact_prefix="codex-windows-sandbox-setup", - dest_dir="codex", - binary_basename="codex-windows-sandbox-setup", - targets=WINDOWS_TARGETS, - ), - "codex-command-runner": BinaryComponent( - artifact_prefix="codex-command-runner", - dest_dir="codex", - binary_basename="codex-command-runner", - targets=WINDOWS_TARGETS, - ), -} - -RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [ - ("x86_64-unknown-linux-musl", "linux-x86_64"), - ("aarch64-unknown-linux-musl", "linux-aarch64"), - ("x86_64-apple-darwin", "macos-x86_64"), - ("aarch64-apple-darwin", "macos-aarch64"), - ("x86_64-pc-windows-msvc", "windows-x86_64"), - ("aarch64-pc-windows-msvc", "windows-aarch64"), -] -RG_TARGET_TO_PLATFORM = {target: platform for target, platform in RG_TARGET_PLATFORM_PAIRS} -DEFAULT_RG_TARGETS = [target for target, _ in RG_TARGET_PLATFORM_PAIRS] - -# urllib.request.urlopen() defaults to no timeout (can hang indefinitely), which is painful in CI. -DOWNLOAD_TIMEOUT_SECS = 60 - - -def _gha_enabled() -> bool: - # GitHub Actions supports "workflow commands" (e.g. ::group:: / ::error::) that make logs - # much easier to scan: groups collapse noisy sections and error annotations surface the - # failure in the UI without changing the actual exception/traceback output. - return os.environ.get("GITHUB_ACTIONS") == "true" - - -def _gha_escape(value: str) -> str: - # Workflow commands require percent/newline escaping. - return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") - - -def _gha_error(*, title: str, message: str) -> None: - # Emit a GitHub Actions error annotation. This does not replace stdout/stderr logs; it just - # adds a prominent summary line to the job UI so the root cause is easier to spot. - if not _gha_enabled(): - return - print( - f"::error title={_gha_escape(title)}::{_gha_escape(message)}", - flush=True, - ) - - -@contextmanager -def _gha_group(title: str): - # Wrap a block in a collapsible log group on GitHub Actions. Outside of GHA this is a no-op - # so local output remains unchanged. - if _gha_enabled(): - print(f"::group::{_gha_escape(title)}", flush=True) - try: - yield - finally: - if _gha_enabled(): - print("::endgroup::", flush=True) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Install native Codex binaries.") - parser.add_argument( - "--workflow-url", - help=( - "GitHub Actions workflow URL that produced the artifacts. Defaults to a " - "known good run when omitted." - ), - ) - parser.add_argument( - "--component", - dest="components", - action="append", - choices=tuple([CODEX_PACKAGE_COMPONENT, *BINARY_COMPONENTS, "rg"]), - help=( - "Limit installation to the specified components." - " May be repeated. Defaults to codex-package and codex-responses-api-proxy." - ), - ) - parser.add_argument( - "--allow-legacy-codex-package", - action="store_true", - help=( - "Allow codex-package to be synthesized from legacy per-binary artifacts " - "when package archives are missing. Intended for CI compatibility only; " - "release staging should not use this. Automatically enabled for the " - "built-in default workflow." - ), - ) - parser.add_argument( - "root", - nargs="?", - type=Path, - help=( - "Directory containing package.json for the staged package. If omitted, the " - "repository checkout is used." - ), - ) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - - codex_cli_root = (args.root or CODEX_CLI_ROOT).resolve() - vendor_dir = codex_cli_root / VENDOR_DIR_NAME - vendor_dir.mkdir(parents=True, exist_ok=True) - - components = args.components or [CODEX_PACKAGE_COMPONENT, "codex-responses-api-proxy"] - - workflow_override = (args.workflow_url or "").strip() - use_default_workflow = not workflow_override - workflow_url = workflow_override or DEFAULT_WORKFLOW_URL - - workflow_id = workflow_url.rstrip("/").split("/")[-1] - print(f"Downloading native artifacts from workflow {workflow_id}...") - - with _gha_group(f"Download native artifacts from workflow {workflow_id}"): - with tempfile.TemporaryDirectory(prefix="codex-native-artifacts-") as artifacts_dir_str: - artifacts_dir = Path(artifacts_dir_str) - _download_artifacts(workflow_id, artifacts_dir) - if CODEX_PACKAGE_COMPONENT in components: - try: - install_codex_package_archives(artifacts_dir, vendor_dir, BINARY_TARGETS) - except FileNotFoundError: - if not (args.allow_legacy_codex_package or use_default_workflow): - raise - install_legacy_codex_package_layouts( - artifacts_dir, - vendor_dir, - BINARY_TARGETS, - manifest_path=RG_MANIFEST, - ) - install_binary_components( - artifacts_dir, - vendor_dir, - [BINARY_COMPONENTS[name] for name in components if name in BINARY_COMPONENTS], - ) - - if "rg" in components: - with _gha_group("Fetch ripgrep binaries"): - print("Fetching ripgrep binaries...") - fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST) - - print(f"Installed native dependencies into {vendor_dir}") - return 0 - - -def install_codex_package_archives( - artifacts_dir: Path, - vendor_dir: Path, - targets: Sequence[str], -) -> None: - targets = list(targets) - if not targets: - return - - print("Installing Codex package archives for targets: " + ", ".join(targets)) - max_workers = min(len(targets), max(1, (os.cpu_count() or 1))) - with ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = { - executor.submit( - _install_single_codex_package_archive, - artifacts_dir, - vendor_dir, - target, - ): target - for target in targets - } - for future in as_completed(futures): - installed_path = future.result() - print(f" installed {installed_path}") - - -def _install_single_codex_package_archive( - artifacts_dir: Path, - vendor_dir: Path, - target: str, -) -> Path: - artifact_subdir = artifact_dir_for_target(artifacts_dir, target) - archive_path = artifact_subdir / f"codex-package-{target}.tar.gz" - if not archive_path.exists(): - raise FileNotFoundError(f"Expected package archive not found: {archive_path}") - - dest_dir = vendor_dir / target - if dest_dir.exists(): - shutil.rmtree(dest_dir) - dest_dir.mkdir(parents=True, exist_ok=True) - - with tarfile.open(archive_path, "r:gz") as archive: - archive.extractall(dest_dir, filter="data") - - return dest_dir - - -def install_legacy_codex_package_layouts( - artifacts_dir: Path, - vendor_dir: Path, - targets: Sequence[str], - *, - manifest_path: Path, -) -> None: - targets = list(targets) - print( - "Synthesizing Codex package layouts from legacy artifacts for targets: " - + ", ".join(targets) - ) - with tempfile.TemporaryDirectory(prefix="codex-legacy-package-") as legacy_vendor_dir_str: - legacy_vendor_dir = Path(legacy_vendor_dir_str) - install_binary_components( - artifacts_dir, - legacy_vendor_dir, - [ - BINARY_COMPONENTS["codex"], - BINARY_COMPONENTS["bwrap"], - BINARY_COMPONENTS["codex-windows-sandbox-setup"], - BINARY_COMPONENTS["codex-command-runner"], - ], - ) - fetch_rg(legacy_vendor_dir, targets, manifest_path=manifest_path) - - for target in targets: - dest_dir = vendor_dir / target - if dest_dir.exists(): - shutil.rmtree(dest_dir) - _build_legacy_codex_package_layout(legacy_vendor_dir / target, dest_dir, target) - print(f" synthesized {dest_dir}") - - -def _build_legacy_codex_package_layout( - legacy_target_dir: Path, - package_dir: Path, - target: str, -) -> None: - is_windows = "windows" in target - exe_suffix = ".exe" if is_windows else "" - package_dir.mkdir(parents=True) - - bin_dir = package_dir / "bin" - resources_dir = package_dir / "codex-resources" - path_dir = package_dir / "codex-path" - bin_dir.mkdir() - resources_dir.mkdir() - path_dir.mkdir() - - shutil.copy2( - legacy_target_dir / "codex" / f"codex{exe_suffix}", - bin_dir / f"codex{exe_suffix}", - ) - shutil.copy2( - legacy_target_dir / "path" / f"rg{exe_suffix}", - path_dir / f"rg{exe_suffix}", - ) - - if is_windows: - for helper in [ - "codex-command-runner.exe", - "codex-windows-sandbox-setup.exe", - ]: - shutil.copy2(legacy_target_dir / "codex" / helper, resources_dir / helper) - elif "linux" in target: - shutil.copy2(legacy_target_dir / "codex-resources" / "bwrap", resources_dir / "bwrap") - - write_json( - package_dir / "codex-package.json", - { - "layoutVersion": 1, - "version": "unknown", - "target": target, - "variant": "codex", - "entrypoint": f"bin/codex{exe_suffix}", - "resourcesDir": "codex-resources", - "pathDir": "codex-path", - }, - ) - - -def fetch_rg( - vendor_dir: Path, - targets: Sequence[str] | None = None, - *, - manifest_path: Path, -) -> list[Path]: - """Download ripgrep binaries described by the DotSlash manifest.""" - - if targets is None: - targets = DEFAULT_RG_TARGETS - - if not manifest_path.exists(): - raise FileNotFoundError(f"DotSlash manifest not found: {manifest_path}") - - manifest = _load_manifest(manifest_path) - platforms = manifest.get("platforms", {}) - - vendor_dir.mkdir(parents=True, exist_ok=True) - - targets = list(targets) - if not targets: - return [] - - task_configs: list[tuple[str, str, dict]] = [] - for target in targets: - platform_key = RG_TARGET_TO_PLATFORM.get(target) - if platform_key is None: - raise ValueError(f"Unsupported ripgrep target '{target}'.") - - platform_info = platforms.get(platform_key) - if platform_info is None: - raise RuntimeError(f"Platform '{platform_key}' not found in manifest {manifest_path}.") - - task_configs.append((target, platform_key, platform_info)) - - results: dict[str, Path] = {} - max_workers = min(len(task_configs), max(1, (os.cpu_count() or 1))) - - print("Installing ripgrep binaries for targets: " + ", ".join(targets)) - - with ThreadPoolExecutor(max_workers=max_workers) as executor: - future_map = { - executor.submit( - _fetch_single_rg, - vendor_dir, - target, - platform_key, - platform_info, - manifest_path, - ): target - for target, platform_key, platform_info in task_configs - } - - for future in as_completed(future_map): - target = future_map[future] - try: - results[target] = future.result() - except Exception as exc: - _gha_error( - title="ripgrep install failed", - message=f"target={target} error={exc!r}", - ) - raise RuntimeError(f"Failed to install ripgrep for target {target}.") from exc - print(f" installed ripgrep for {target}") - - return [results[target] for target in targets] - - -def _download_artifacts(workflow_id: str, dest_dir: Path) -> None: - cmd = [ - "gh", - "run", - "download", - "--dir", - str(dest_dir), - "--repo", - "openai/codex", - workflow_id, - ] - subprocess.check_call(cmd) - - -def install_binary_components( - artifacts_dir: Path, - vendor_dir: Path, - selected_components: Sequence[BinaryComponent], -) -> None: - if not selected_components: - return - - for component in selected_components: - component_targets = list(component.targets or BINARY_TARGETS) - - print( - f"Installing {component.binary_basename} binaries for targets: " - + ", ".join(component_targets) - ) - max_workers = min(len(component_targets), max(1, (os.cpu_count() or 1))) - with ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = { - executor.submit( - _install_single_binary, - artifacts_dir, - vendor_dir, - target, - component, - ): target - for target in component_targets - } - for future in as_completed(futures): - installed_path = future.result() - print(f" installed {installed_path}") - - -def _install_single_binary( - artifacts_dir: Path, - vendor_dir: Path, - target: str, - component: BinaryComponent, -) -> Path: - artifact_subdir = artifact_dir_for_target(artifacts_dir, target) - archive_path = legacy_binary_archive_path(artifact_subdir, component.artifact_prefix, target) - - dest_dir = vendor_dir / target / component.dest_dir - dest_dir.mkdir(parents=True, exist_ok=True) - - binary_name = ( - f"{component.binary_basename}.exe" if "windows" in target else component.binary_basename - ) - dest = dest_dir / binary_name - dest.unlink(missing_ok=True) - extract_archive(archive_path, "zst", None, dest) - if "windows" not in target: - dest.chmod(0o755) - return dest - - -def _archive_name_for_target(artifact_prefix: str, target: str) -> str: - if "windows" in target: - return f"{artifact_prefix}-{target}.exe.zst" - return f"{artifact_prefix}-{target}.zst" - - -def legacy_binary_archive_path(artifact_dir: Path, artifact_prefix: str, target: str) -> Path: - archive_names = [_archive_name_for_target(artifact_prefix, target)] - if artifact_dir.name == f"{target}-unsigned": - archive_names.append(_archive_name_for_target(artifact_prefix, f"{target}-unsigned")) - - for archive_name in archive_names: - archive_path = artifact_dir / archive_name - if archive_path.exists(): - return archive_path - - raise FileNotFoundError(f"Expected artifact not found: {artifact_dir / archive_names[0]}") - - -def artifact_dir_for_target(artifacts_dir: Path, target: str) -> Path: - for artifact_name in [target, f"{target}-unsigned"]: - artifact_dir = artifacts_dir / artifact_name - if artifact_dir.is_dir(): - return artifact_dir - - return artifacts_dir / target - - -def _fetch_single_rg( - vendor_dir: Path, - target: str, - platform_key: str, - platform_info: dict, - manifest_path: Path, -) -> Path: - providers = platform_info.get("providers", []) - if not providers: - raise RuntimeError(f"No providers listed for platform '{platform_key}' in {manifest_path}.") - - url = providers[0]["url"] - archive_format = platform_info.get("format", "zst") - archive_member = platform_info.get("path") - digest = platform_info.get("digest") - expected_size = platform_info.get("size") - - dest_dir = vendor_dir / target / "path" - dest_dir.mkdir(parents=True, exist_ok=True) - - is_windows = platform_key.startswith("win") - binary_name = "rg.exe" if is_windows else "rg" - dest = dest_dir / binary_name - - with tempfile.TemporaryDirectory() as tmp_dir_str: - tmp_dir = Path(tmp_dir_str) - archive_filename = os.path.basename(urlparse(url).path) - download_path = tmp_dir / archive_filename - print( - f" downloading ripgrep for {target} ({platform_key}) from {url}", - flush=True, - ) - try: - _download_file(url, download_path) - except Exception as exc: - _gha_error( - title="ripgrep download failed", - message=f"target={target} platform={platform_key} url={url} error={exc!r}", - ) - raise RuntimeError( - "Failed to download ripgrep " - f"(target={target}, platform={platform_key}, format={archive_format}, " - f"expected_size={expected_size!r}, digest={digest!r}, url={url}, dest={download_path})." - ) from exc - - dest.unlink(missing_ok=True) - try: - extract_archive(download_path, archive_format, archive_member, dest) - except Exception as exc: - raise RuntimeError( - "Failed to extract ripgrep " - f"(target={target}, platform={platform_key}, format={archive_format}, " - f"member={archive_member!r}, url={url}, archive={download_path})." - ) from exc - - if not is_windows: - dest.chmod(0o755) - - return dest - - -def _download_file(url: str, dest: Path) -> None: - dest.parent.mkdir(parents=True, exist_ok=True) - dest.unlink(missing_ok=True) - - with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response, open(dest, "wb") as out: - shutil.copyfileobj(response, out) - - -def extract_archive( - archive_path: Path, - archive_format: str, - archive_member: str | None, - dest: Path, -) -> None: - dest.parent.mkdir(parents=True, exist_ok=True) - - if archive_format == "zst": - output_path = archive_path.parent / dest.name - subprocess.check_call( - ["zstd", "-f", "-d", str(archive_path), "-o", str(output_path)] - ) - shutil.move(str(output_path), dest) - return - - if archive_format == "tar.gz": - if not archive_member: - raise RuntimeError("Missing 'path' for tar.gz archive in DotSlash manifest.") - with tarfile.open(archive_path, "r:gz") as tar: - try: - member = tar.getmember(archive_member) - except KeyError as exc: - raise RuntimeError( - f"Entry '{archive_member}' not found in archive {archive_path}." - ) from exc - tar.extract(member, path=archive_path.parent, filter="data") - extracted = archive_path.parent / archive_member - shutil.move(str(extracted), dest) - return - - if archive_format == "zip": - if not archive_member: - raise RuntimeError("Missing 'path' for zip archive in DotSlash manifest.") - with zipfile.ZipFile(archive_path) as archive: - try: - with archive.open(archive_member) as src, open(dest, "wb") as out: - shutil.copyfileobj(src, out) - except KeyError as exc: - raise RuntimeError( - f"Entry '{archive_member}' not found in archive {archive_path}." - ) from exc - return - - raise RuntimeError(f"Unsupported archive format '{archive_format}'.") - - -def _load_manifest(manifest_path: Path) -> dict: - cmd = ["dotslash", "--", "parse", str(manifest_path)] - stdout = subprocess.check_output(cmd, text=True) - try: - manifest = json.loads(stdout) - except json.JSONDecodeError as exc: - raise RuntimeError(f"Invalid DotSlash manifest output from {manifest_path}.") from exc - - if not isinstance(manifest, dict): - raise RuntimeError( - f"Unexpected DotSlash manifest structure for {manifest_path}: {type(manifest)!r}" - ) - - return manifest - - -def write_json(path: Path, value: object) -> None: - with open(path, "w", encoding="utf-8") as out: - json.dump(value, out, indent=2) - out.write("\n") - - -if __name__ == "__main__": - import sys - - sys.exit(main()) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3fa4d8315b1c..bf018b43a06f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -402,7 +402,7 @@ checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "app_test_support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -1778,7 +1778,7 @@ dependencies = [ [[package]] name = "codex-agent-graph-store" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-protocol", @@ -1793,7 +1793,7 @@ dependencies = [ [[package]] name = "codex-agent-identity" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -1812,7 +1812,7 @@ dependencies = [ [[package]] name = "codex-analytics" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-app-server-protocol", "codex-git-utils", @@ -1832,7 +1832,7 @@ dependencies = [ [[package]] name = "codex-ansi-escape" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "ansi-to-tui", "ratatui", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "codex-api" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_matches", @@ -1875,7 +1875,7 @@ dependencies = [ [[package]] name = "codex-app-server" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "app_test_support", @@ -1960,7 +1960,7 @@ dependencies = [ [[package]] name = "codex-app-server-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-app-server", "codex-app-server-protocol", @@ -1987,7 +1987,7 @@ dependencies = [ [[package]] name = "codex-app-server-daemon" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -2008,7 +2008,7 @@ dependencies = [ [[package]] name = "codex-app-server-protocol" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2035,7 +2035,7 @@ dependencies = [ [[package]] name = "codex-app-server-test-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2056,7 +2056,7 @@ dependencies = [ [[package]] name = "codex-app-server-transport" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "axum", @@ -2095,7 +2095,7 @@ dependencies = [ [[package]] name = "codex-apply-patch" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -2114,7 +2114,7 @@ dependencies = [ [[package]] name = "codex-arg0" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-apply-patch", @@ -2123,6 +2123,7 @@ dependencies = [ "codex-sandboxing", "codex-shell-escalation", "codex-utils-absolute-path", + "codex-utils-file-lock", "codex-utils-home-dir", "dotenvy", "tempfile", @@ -2131,7 +2132,7 @@ dependencies = [ [[package]] name = "codex-async-utils" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "pretty_assertions", @@ -2141,7 +2142,7 @@ dependencies = [ [[package]] name = "codex-aws-auth" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "aws-config", "aws-credential-types", @@ -2156,7 +2157,7 @@ dependencies = [ [[package]] name = "codex-backend-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-api", @@ -2173,7 +2174,7 @@ dependencies = [ [[package]] name = "codex-backend-openapi-models" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "serde", "serde_json", @@ -2182,7 +2183,7 @@ dependencies = [ [[package]] name = "codex-bwrap" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "cc", "libc", @@ -2191,7 +2192,7 @@ dependencies = [ [[package]] name = "codex-chatgpt" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2214,7 +2215,7 @@ dependencies = [ [[package]] name = "codex-cli" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -2279,7 +2280,7 @@ dependencies = [ [[package]] name = "codex-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "bytes", @@ -2310,7 +2311,7 @@ dependencies = [ [[package]] name = "codex-cloud-requirements" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -2336,7 +2337,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -2368,7 +2369,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -2383,7 +2384,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks-mock-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "chrono", @@ -2393,7 +2394,7 @@ dependencies = [ [[package]] name = "codex-code-mode" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-channel", "async-trait", @@ -2410,11 +2411,11 @@ dependencies = [ [[package]] name = "codex-collaboration-mode-templates" -version = "0.0.0" +version = "0.132.0-alpha.1" [[package]] name = "codex-config" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -2462,7 +2463,7 @@ dependencies = [ [[package]] name = "codex-connectors" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -2478,7 +2479,7 @@ dependencies = [ [[package]] name = "codex-core" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "arc-swap", @@ -2532,6 +2533,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-cache", "codex-utils-cargo-bin", + "codex-utils-file-lock", "codex-utils-home-dir", "codex-utils-image", "codex-utils-output-truncation", @@ -2596,7 +2598,7 @@ dependencies = [ [[package]] name = "codex-core-api" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-analytics", "codex-app-server-protocol", @@ -2615,7 +2617,7 @@ dependencies = [ [[package]] name = "codex-core-plugins" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -2654,7 +2656,7 @@ dependencies = [ [[package]] name = "codex-core-skills" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-analytics", @@ -2685,7 +2687,7 @@ dependencies = [ [[package]] name = "codex-debug-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2697,7 +2699,7 @@ dependencies = [ [[package]] name = "codex-exec" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -2743,7 +2745,7 @@ dependencies = [ [[package]] name = "codex-exec-server" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "arc-swap", @@ -2784,11 +2786,12 @@ dependencies = [ [[package]] name = "codex-execpolicy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", "codex-utils-absolute-path", + "codex-utils-file-lock", "multimap", "pretty_assertions", "serde", @@ -2801,7 +2804,7 @@ dependencies = [ [[package]] name = "codex-execpolicy-legacy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "allocative", "anyhow", @@ -2821,7 +2824,7 @@ dependencies = [ [[package]] name = "codex-experimental-api-macros" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "proc-macro2", "quote", @@ -2830,7 +2833,7 @@ dependencies = [ [[package]] name = "codex-extension-api" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-protocol", @@ -2839,7 +2842,7 @@ dependencies = [ [[package]] name = "codex-external-agent-migration" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-hooks", "pretty_assertions", @@ -2851,7 +2854,7 @@ dependencies = [ [[package]] name = "codex-external-agent-sessions" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "chrono", "codex-app-server-protocol", @@ -2865,7 +2868,7 @@ dependencies = [ [[package]] name = "codex-features" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-otel", "codex-protocol", @@ -2878,7 +2881,7 @@ dependencies = [ [[package]] name = "codex-feedback" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-login", @@ -2891,7 +2894,7 @@ dependencies = [ [[package]] name = "codex-file-search" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -2907,7 +2910,7 @@ dependencies = [ [[package]] name = "codex-file-system" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-protocol", @@ -2917,7 +2920,7 @@ dependencies = [ [[package]] name = "codex-file-watcher" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "notify", "pretty_assertions", @@ -2928,7 +2931,7 @@ dependencies = [ [[package]] name = "codex-git-utils" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -2952,7 +2955,7 @@ dependencies = [ [[package]] name = "codex-goal-extension" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -2972,7 +2975,7 @@ dependencies = [ [[package]] name = "codex-guardian" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-core", @@ -2982,7 +2985,7 @@ dependencies = [ [[package]] name = "codex-hooks" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -3005,7 +3008,7 @@ dependencies = [ [[package]] name = "codex-install-context" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-absolute-path", "codex-utils-home-dir", @@ -3015,7 +3018,7 @@ dependencies = [ [[package]] name = "codex-keyring-store" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "keyring", "tracing", @@ -3023,7 +3026,7 @@ dependencies = [ [[package]] name = "codex-linux-sandbox" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "clap", "codex-core", @@ -3047,7 +3050,7 @@ dependencies = [ [[package]] name = "codex-lmstudio" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-core", "codex-model-provider-info", @@ -3061,7 +3064,7 @@ dependencies = [ [[package]] name = "codex-login" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3103,7 +3106,7 @@ dependencies = [ [[package]] name = "codex-mcp" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-channel", @@ -3135,7 +3138,7 @@ dependencies = [ [[package]] name = "codex-mcp-server" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-arg0", @@ -3167,7 +3170,7 @@ dependencies = [ [[package]] name = "codex-memories-extension" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-core", @@ -3188,7 +3191,7 @@ dependencies = [ [[package]] name = "codex-memories-mcp" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-utils-absolute-path", @@ -3205,7 +3208,7 @@ dependencies = [ [[package]] name = "codex-memories-read" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-protocol", "codex-shell-command", @@ -3219,7 +3222,7 @@ dependencies = [ [[package]] name = "codex-memories-write" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -3254,7 +3257,7 @@ dependencies = [ [[package]] name = "codex-message-history" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-config", "pretty_assertions", @@ -3267,7 +3270,7 @@ dependencies = [ [[package]] name = "codex-model-provider" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-agent-identity", @@ -3291,7 +3294,7 @@ dependencies = [ [[package]] name = "codex-model-provider-info" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-api", "codex-app-server-protocol", @@ -3308,7 +3311,7 @@ dependencies = [ [[package]] name = "codex-models-manager" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "chrono", @@ -3329,7 +3332,7 @@ dependencies = [ [[package]] name = "codex-network-proxy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3360,7 +3363,7 @@ dependencies = [ [[package]] name = "codex-ollama" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "assert_matches", "async-stream", @@ -3379,7 +3382,7 @@ dependencies = [ [[package]] name = "codex-otel" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "chrono", "codex-api", @@ -3411,7 +3414,7 @@ dependencies = [ [[package]] name = "codex-plugin" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-config", "codex-utils-absolute-path", @@ -3421,7 +3424,7 @@ dependencies = [ [[package]] name = "codex-process-hardening" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "libc", "pretty_assertions", @@ -3429,7 +3432,7 @@ dependencies = [ [[package]] name = "codex-protocol" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chardetng", @@ -3469,7 +3472,7 @@ dependencies = [ [[package]] name = "codex-realtime-webrtc" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "libwebrtc", "thiserror 2.0.18", @@ -3478,7 +3481,7 @@ dependencies = [ [[package]] name = "codex-response-debug-context" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "base64 0.22.1", "codex-api", @@ -3489,7 +3492,7 @@ dependencies = [ [[package]] name = "codex-responses-api-proxy" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -3506,7 +3509,7 @@ dependencies = [ [[package]] name = "codex-rmcp-client" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "axum", @@ -3544,7 +3547,7 @@ dependencies = [ [[package]] name = "codex-rollout" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3558,6 +3561,7 @@ dependencies = [ "codex-utils-path", "codex-utils-string", "pretty_assertions", + "regex", "serde", "serde_json", "tempfile", @@ -3569,7 +3573,7 @@ dependencies = [ [[package]] name = "codex-rollout-trace" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-code-mode", @@ -3585,7 +3589,7 @@ dependencies = [ [[package]] name = "codex-sandboxing" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3606,7 +3610,7 @@ dependencies = [ [[package]] name = "codex-secrets" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "age", "anyhow", @@ -3627,7 +3631,7 @@ dependencies = [ [[package]] name = "codex-shell-command" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -3647,7 +3651,7 @@ dependencies = [ [[package]] name = "codex-shell-escalation" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "async-trait", @@ -3668,7 +3672,7 @@ dependencies = [ [[package]] name = "codex-skills" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-absolute-path", "include_dir", @@ -3677,7 +3681,7 @@ dependencies = [ [[package]] name = "codex-state" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -3700,7 +3704,7 @@ dependencies = [ [[package]] name = "codex-stdio-to-uds" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-uds", @@ -3712,7 +3716,7 @@ dependencies = [ [[package]] name = "codex-terminal-detection" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "tracing", @@ -3720,7 +3724,7 @@ dependencies = [ [[package]] name = "codex-test-binary-support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-arg0", "tempfile", @@ -3728,7 +3732,7 @@ dependencies = [ [[package]] name = "codex-thread-manager-sample" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "clap", @@ -3739,7 +3743,7 @@ dependencies = [ [[package]] name = "codex-thread-store" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "chrono", @@ -3761,7 +3765,7 @@ dependencies = [ [[package]] name = "codex-tools" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-trait", "codex-app-server-protocol", @@ -3769,20 +3773,23 @@ dependencies = [ "codex-features", "codex-protocol", "codex-utils-absolute-path", + "codex-utils-cargo-bin", "codex-utils-output-truncation", "codex-utils-pty", "codex-utils-string", + "jsonptr", "pretty_assertions", "rmcp", "serde", "serde_json", "thiserror 2.0.18", "tracing", + "urlencoding", ] [[package]] name = "codex-tui" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "arboard", @@ -3893,7 +3900,7 @@ dependencies = [ [[package]] name = "codex-uds" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "async-io", "pretty_assertions", @@ -3905,7 +3912,7 @@ dependencies = [ [[package]] name = "codex-utils-absolute-path" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "dirs", "dunce", @@ -3919,14 +3926,14 @@ dependencies = [ [[package]] name = "codex-utils-approval-presets" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-protocol", ] [[package]] name = "codex-utils-cache" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "lru 0.16.3", "sha1", @@ -3935,7 +3942,7 @@ dependencies = [ [[package]] name = "codex-utils-cargo-bin" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "assert_cmd", "runfiles", @@ -3944,7 +3951,7 @@ dependencies = [ [[package]] name = "codex-utils-cli" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "clap", "codex-protocol", @@ -3956,15 +3963,19 @@ dependencies = [ [[package]] name = "codex-utils-elapsed" -version = "0.0.0" +version = "0.132.0-alpha.1" + +[[package]] +name = "codex-utils-file-lock" +version = "0.132.0-alpha.1" [[package]] name = "codex-utils-fuzzy-match" -version = "0.0.0" +version = "0.132.0-alpha.1" [[package]] name = "codex-utils-home-dir" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-absolute-path", "dirs", @@ -3974,10 +3985,11 @@ dependencies = [ [[package]] name = "codex-utils-image" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "base64 0.22.1", "codex-utils-cache", + "divan", "image", "mime_guess", "thiserror 2.0.18", @@ -3986,7 +3998,7 @@ dependencies = [ [[package]] name = "codex-utils-json-to-toml" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "serde_json", @@ -3995,7 +4007,7 @@ dependencies = [ [[package]] name = "codex-utils-oss" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-core", "codex-lmstudio", @@ -4005,7 +4017,7 @@ dependencies = [ [[package]] name = "codex-utils-output-truncation" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-protocol", "codex-utils-string", @@ -4014,7 +4026,7 @@ dependencies = [ [[package]] name = "codex-utils-path" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-utils-absolute-path", "dunce", @@ -4024,7 +4036,7 @@ dependencies = [ [[package]] name = "codex-utils-plugins" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-exec-server", "codex-login", @@ -4037,7 +4049,7 @@ dependencies = [ [[package]] name = "codex-utils-pty" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "filedescriptor", @@ -4053,7 +4065,7 @@ dependencies = [ [[package]] name = "codex-utils-readiness" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "assert_matches", "async-trait", @@ -4064,14 +4076,14 @@ dependencies = [ [[package]] name = "codex-utils-rustls-provider" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "rustls", ] [[package]] name = "codex-utils-sandbox-summary" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "codex-core", "codex-model-provider-info", @@ -4082,7 +4094,7 @@ dependencies = [ [[package]] name = "codex-utils-sleep-inhibitor" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "core-foundation 0.9.4", "libc", @@ -4092,14 +4104,14 @@ dependencies = [ [[package]] name = "codex-utils-stream-parser" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", ] [[package]] name = "codex-utils-string" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "regex-lite", @@ -4109,14 +4121,14 @@ dependencies = [ [[package]] name = "codex-utils-template" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", ] [[package]] name = "codex-v8-poc" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "pretty_assertions", "v8", @@ -4124,7 +4136,7 @@ dependencies = [ [[package]] name = "codex-windows-sandbox" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -4143,6 +4155,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "tracing-appender", "windows 0.58.0", "windows-sys 0.52.0", ] @@ -4219,6 +4232,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "condtype" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" + [[package]] name = "console" version = "0.15.11" @@ -4374,7 +4393,7 @@ dependencies = [ [[package]] name = "core_test_support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "assert_cmd", @@ -5215,6 +5234,31 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "divan" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a405457ec78b8fe08b0e32b4a3570ab5dff6dd16eb9e76a5ee0a9d9cbd898933" +dependencies = [ + "cfg-if", + "clap", + "condtype", + "divan-macros", + "libc", + "regex-lite", +] + +[[package]] +name = "divan-macros" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9556bc800956545d6420a640173e5ba7dfa82f38d3ea5a167eb555bc69ac3323" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "dns-lookup" version = "3.0.1" @@ -5488,7 +5532,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8124,6 +8168,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" + [[package]] name = "jsonwebtoken" version = "9.3.1" @@ -8573,7 +8623,7 @@ dependencies = [ [[package]] name = "mcp_test_support" -version = "0.0.0" +version = "0.132.0-alpha.1" dependencies = [ "anyhow", "codex-login", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ec38a87cff97..44aeba13be78 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -81,6 +81,7 @@ members = [ "v8-poc", "utils/absolute-path", "utils/cargo-bin", + "utils/file-lock", "git-utils", "utils/cache", "utils/image", @@ -117,7 +118,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.0.0" +version = "0.134.0-alpha.3" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 @@ -219,6 +220,7 @@ codex-utils-cache = { path = "utils/cache" } codex-utils-cargo-bin = { path = "utils/cargo-bin" } codex-utils-cli = { path = "utils/cli" } codex-utils-elapsed = { path = "utils/elapsed" } +codex-utils-file-lock = { path = "utils/file-lock" } codex-utils-fuzzy-match = { path = "utils/fuzzy-match" } codex-utils-home-dir = { path = "utils/home-dir" } codex-utils-image = { path = "utils/image" } @@ -275,6 +277,7 @@ deno_core_icudata = "0.77.0" derive_more = "2" diffy = "0.4.2" dirs = "6" +divan = "0.1.21" dns-lookup = "3.0.1" dotenvy = "0.15.7" dunce = "1.0.4" @@ -301,6 +304,7 @@ indexmap = "2.12.0" insta = "1.46.3" inventory = "0.3.19" itertools = "0.14.0" +jsonptr = { version = "0.7.1", default-features = false } jsonwebtoken = "9.3.1" keyring = { version = "3.6", default-features = false } landlock = "0.4.4" diff --git a/codex-rs/README.md b/codex-rs/README.md index d219061a350e..18bffd9f6424 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -55,25 +55,20 @@ Use `codex exec --ephemeral ...` to run without persisting session rollout files ### Experimenting with the Codex Sandbox -To test to see what happens when a command is run under the sandbox provided by Codex, we provide the following subcommands in Codex CLI: +To test to see what happens when a command is run under the sandbox provided by Codex, use the `sandbox` subcommand in Codex CLI: ``` -# macOS -codex sandbox macos [--log-denials] [COMMAND]... +# Uses the sandbox implementation for the current host OS: +# Seatbelt on macOS, the Linux sandbox on Linux, and Windows restricted token on Windows. +codex sandbox [COMMAND]... -# Linux -codex sandbox linux [COMMAND]... - -# Windows -codex sandbox windows [COMMAND]... - -# Legacy aliases -codex debug seatbelt [--log-denials] [COMMAND]... -codex debug landlock [COMMAND]... +# macOS-only diagnostic option +codex sandbox --log-denials [COMMAND]... ``` -To try a writable legacy sandbox mode with these commands, pass an explicit config override such -as `-c 'sandbox_mode="workspace-write"'`. +`codex sandbox` also accepts `--profile NAME` (`-p NAME`) to layer +`$CODEX_HOME/NAME.config.toml` onto the base user config for the sandboxed +command. ### Selecting a sandbox policy via `--sandbox` @@ -90,7 +85,6 @@ codex --sandbox workspace-write codex --sandbox danger-full-access ``` -The same setting can be persisted in `~/.codex/config.toml` via the top-level `sandbox_mode = "MODE"` key, e.g. `sandbox_mode = "workspace-write"`. In `workspace-write`, Codex also includes `~/.codex/memories` in its writable roots so memory maintenance does not require an extra approval. ## Code Organization diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index ef5051cabaf2..cabea808865d 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1262,6 +1262,14 @@ fn compaction_event_serializes_expected_shape() { ); } +#[test] +fn compaction_implementation_serializes_remote_v2() { + let payload = serde_json::to_value(CompactionImplementation::ResponsesCompactionV2) + .expect("serialize compaction implementation"); + + assert_eq!(payload, json!("responses_compaction_v2")); +} + #[test] fn app_used_dedupe_is_keyed_by_turn_and_connector() { let (sender, _receiver) = mpsc::channel(1); diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index d7e2c069d6c2..56bd0a5d2c3e 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -229,6 +229,7 @@ pub enum CompactionReason { #[serde(rename_all = "snake_case")] pub enum CompactionImplementation { Responses, + ResponsesCompactionV2, ResponsesCompact, } diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 97b5f698c27c..7595527c2125 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -423,7 +423,6 @@ ] }, "includeLayers": { - "default": false, "type": "boolean" } }, @@ -721,8 +720,7 @@ } }, "required": [ - "classification", - "includeLogs" + "classification" ], "type": "object" }, @@ -1034,7 +1032,6 @@ "GetAccountParams": { "properties": { "refreshToken": { - "default": false, "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`.", "type": "boolean" } @@ -3424,7 +3421,6 @@ "ThreadReadParams": { "properties": { "includeTurns": { - "default": false, "description": "When true, include turns and their items from rollout history.", "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index dfb999cf314f..90899cb15278 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -6546,4 +6546,4 @@ } ], "title": "ServerNotification" -} +} \ 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 155afbb29b0c..a7384050362c 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 @@ -7361,19 +7361,6 @@ } ] }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "profiles": { - "additionalProperties": { - "$ref": "#/definitions/v2/ProfileV2" - }, - "default": {}, - "type": "object" - }, "review_model": { "type": [ "string", @@ -7691,7 +7678,6 @@ ] }, "includeLayers": { - "default": false, "type": "boolean" } }, @@ -7729,6 +7715,12 @@ }, "ConfigRequirements": { "properties": { + "allowAppshots": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", @@ -8593,8 +8585,7 @@ } }, "required": [ - "classification", - "includeLogs" + "classification" ], "title": "FeedbackUploadParams", "type": "object" @@ -9370,7 +9361,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "refreshToken": { - "default": false, "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`.", "type": "boolean" } @@ -13173,107 +13163,6 @@ ], "type": "object" }, - "ProfileV2": { - "additionalProperties": true, - "properties": { - "approval_policy": { - "anyOf": [ - { - "$ref": "#/definitions/v2/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "approvals_reviewer": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ApprovalsReviewer" - }, - { - "type": "null" - } - ], - "description": "[UNSTABLE] Optional profile-level override for where approval requests are routed for review. If omitted, the enclosing config default is used." - }, - "chatgpt_base_url": { - "type": [ - "string", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "model_provider": { - "type": [ - "string", - "null" - ] - }, - "model_reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "model_reasoning_summary": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ReasoningSummary" - }, - { - "type": "null" - } - ] - }, - "model_verbosity": { - "anyOf": [ - { - "$ref": "#/definitions/v2/Verbosity" - }, - { - "type": "null" - } - ] - }, - "service_tier": { - "type": [ - "string", - "null" - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ToolsV2" - }, - { - "type": "null" - } - ] - }, - "web_search": { - "anyOf": [ - { - "$ref": "#/definitions/v2/WebSearchMode" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, "RateLimitReachedType": { "enum": [ "rate_limit_reached", @@ -16965,7 +16854,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "includeTurns": { - "default": false, "description": "When true, include turns and their items from rollout history.", "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index a636cf1de38e..ac565328f9dc 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 @@ -3730,19 +3730,6 @@ } ] }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "profiles": { - "additionalProperties": { - "$ref": "#/definitions/ProfileV2" - }, - "default": {}, - "type": "object" - }, "review_model": { "type": [ "string", @@ -4060,7 +4047,6 @@ ] }, "includeLayers": { - "default": false, "type": "boolean" } }, @@ -4098,6 +4084,12 @@ }, "ConfigRequirements": { "properties": { + "allowAppshots": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", @@ -4962,8 +4954,7 @@ } }, "required": [ - "classification", - "includeLogs" + "classification" ], "title": "FeedbackUploadParams", "type": "object" @@ -5850,7 +5841,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "refreshToken": { - "default": false, "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`.", "type": "boolean" } @@ -9702,107 +9692,6 @@ ], "type": "object" }, - "ProfileV2": { - "additionalProperties": true, - "properties": { - "approval_policy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "approvals_reviewer": { - "anyOf": [ - { - "$ref": "#/definitions/ApprovalsReviewer" - }, - { - "type": "null" - } - ], - "description": "[UNSTABLE] Optional profile-level override for where approval requests are routed for review. If omitted, the enclosing config default is used." - }, - "chatgpt_base_url": { - "type": [ - "string", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "model_provider": { - "type": [ - "string", - "null" - ] - }, - "model_reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "model_reasoning_summary": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningSummary" - }, - { - "type": "null" - } - ] - }, - "model_verbosity": { - "anyOf": [ - { - "$ref": "#/definitions/Verbosity" - }, - { - "type": "null" - } - ] - }, - "service_tier": { - "type": [ - "string", - "null" - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsV2" - }, - { - "type": "null" - } - ] - }, - "web_search": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchMode" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, "RateLimitReachedType": { "enum": [ "rate_limit_reached", @@ -14789,7 +14678,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "includeTurns": { - "default": false, "description": "When true, include turns and their items from rollout history.", "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadParams.json index b173d2ba953d..db38089a5d2d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadParams.json @@ -9,7 +9,6 @@ ] }, "includeLayers": { - "default": false, "type": "boolean" } }, 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 7595f7fd0093..4a104b3bd519 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -352,19 +352,6 @@ } ] }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "profiles": { - "additionalProperties": { - "$ref": "#/definitions/ProfileV2" - }, - "default": {}, - "type": "object" - }, "review_model": { "type": [ "string", @@ -642,107 +629,6 @@ ], "type": "string" }, - "ProfileV2": { - "additionalProperties": true, - "properties": { - "approval_policy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "approvals_reviewer": { - "anyOf": [ - { - "$ref": "#/definitions/ApprovalsReviewer" - }, - { - "type": "null" - } - ], - "description": "[UNSTABLE] Optional profile-level override for where approval requests are routed for review. If omitted, the enclosing config default is used." - }, - "chatgpt_base_url": { - "type": [ - "string", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "model_provider": { - "type": [ - "string", - "null" - ] - }, - "model_reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "model_reasoning_summary": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningSummary" - }, - { - "type": "null" - } - ] - }, - "model_verbosity": { - "anyOf": [ - { - "$ref": "#/definitions/Verbosity" - }, - { - "type": "null" - } - ] - }, - "service_tier": { - "type": [ - "string", - "null" - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsV2" - }, - { - "type": "null" - } - ] - }, - "web_search": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchMode" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index d25ca854af91..63a209cc7795 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -73,6 +73,12 @@ }, "ConfigRequirements": { "properties": { + "allowAppshots": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", diff --git a/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json b/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json index 07c20986067f..3bf0c6219459 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json @@ -39,8 +39,7 @@ } }, "required": [ - "classification", - "includeLogs" + "classification" ], "title": "FeedbackUploadParams", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountParams.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountParams.json index ca18a451e948..445e90c101fc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountParams.json @@ -2,7 +2,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "refreshToken": { - "default": false, "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`.", "type": "boolean" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadParams.json index f5e5503cc0b0..5fb1bcc17b08 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadParams.json @@ -2,7 +2,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "includeTurns": { - "default": false, "description": "When true, include turns and their items from rollout history.", "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts index 29eae9877419..cc15fb4e720b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts @@ -12,7 +12,6 @@ import type { AnalyticsConfig } from "./AnalyticsConfig"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { ForcedChatgptWorkspaceIds } from "./ForcedChatgptWorkspaceIds"; -import type { ProfileV2 } from "./ProfileV2"; import type { SandboxMode } from "./SandboxMode"; import type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite"; import type { ToolsV2 } from "./ToolsV2"; @@ -21,4 +20,4 @@ export type Config = {model: string | null, review_model: string | null, model_c * [UNSTABLE] Optional default for where approval requests are routed for * review. */ -approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null, desktop: { [key in string]?: JsonValue } | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null, desktop: { [key in string]?: JsonValue } | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigReadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigReadParams.ts index 1fd418d1820d..7acf72c8506b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigReadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigReadParams.ts @@ -2,7 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ConfigReadParams = { includeLayers: boolean, +export type ConfigReadParams = { includeLayers?: boolean, /** * Optional working directory to resolve project config layers. If specified, * return the effective config as seen from that directory (i.e., including any diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 8653da78cded..c5f3895866a6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -7,4 +7,4 @@ import type { ComputerUseRequirements } from "./ComputerUseRequirements"; import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedPermissions: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedPermissions: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts index 86d9de2f0da6..2afabd6e4ada 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, extraLogFiles?: Array | null, tags?: { [key in string]?: string } | null, }; +export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs?: boolean, extraLogFiles?: Array | null, tags?: { [key in string]?: string } | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountParams.ts index a5c5c25f6647..9e82ef5e13a9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountParams.ts @@ -10,4 +10,4 @@ export type GetAccountParams = { * external auth mode this flag is ignored. Clients should refresh tokens * themselves and call `account/login/start` with `chatgptAuthTokens`. */ -refreshToken: boolean, }; +refreshToken?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts deleted file mode 100644 index d05038701c83..000000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts +++ /dev/null @@ -1,18 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ReasoningEffort } from "../ReasoningEffort"; -import type { ReasoningSummary } from "../ReasoningSummary"; -import type { Verbosity } from "../Verbosity"; -import type { WebSearchMode } from "../WebSearchMode"; -import type { JsonValue } from "../serde_json/JsonValue"; -import type { ApprovalsReviewer } from "./ApprovalsReviewer"; -import type { AskForApproval } from "./AskForApproval"; -import type { ToolsV2 } from "./ToolsV2"; - -export type ProfileV2 = {model: string | null, model_provider: string | null, approval_policy: AskForApproval | null, /** - * [UNSTABLE] Optional profile-level override for where approval requests - * are routed for review. If omitted, the enclosing config default is - * used. - */ -approvals_reviewer: ApprovalsReviewer | null, service_tier: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadReadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadReadParams.ts index 64169d2bf665..c26e89648195 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadReadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadReadParams.ts @@ -6,4 +6,4 @@ export type ThreadReadParams = { threadId: string, /** * When true, include turns and their items from rollout history. */ -includeTurns: boolean, }; +includeTurns?: boolean, }; 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 d5ae15e8e279..5b4f2ed2831a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -308,7 +308,6 @@ export type { ProcessExitedNotification } from "./ProcessExitedNotification"; export type { ProcessOutputDeltaNotification } from "./ProcessOutputDeltaNotification"; export type { ProcessOutputStream } from "./ProcessOutputStream"; export type { ProcessTerminalSize } from "./ProcessTerminalSize"; -export type { ProfileV2 } from "./ProfileV2"; export type { RateLimitReachedType } from "./RateLimitReachedType"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index 2fcf54f4bee8..f6d7670e10a2 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -36,7 +36,6 @@ pub use protocol::v1::InitializeParams; pub use protocol::v1::InitializeResponse; pub use protocol::v1::InterruptConversationResponse; pub use protocol::v1::LoginApiKeyParams; -pub use protocol::v1::Profile; pub use protocol::v1::SandboxSettings; pub use protocol::v1::Tools; pub use protocol::v1::UserSavedConfig; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index dd1971116867..a8bec6b40255 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2547,8 +2547,22 @@ mod tests { json!({ "method": "account/read", "id": 6, + "params": {} + }), + serde_json::to_value(&request)?, + ); + let request = ClientRequest::GetAccount { + request_id: RequestId::Integer(7), + params: v2::GetAccountParams { + refresh_token: true, + }, + }; + assert_eq!( + json!({ + "method": "account/read", + "id": 7, "params": { - "refreshToken": false + "refreshToken": true } }), serde_json::to_value(&request)?, diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 0fbb93f39757..c0c7d5552839 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1393,6 +1393,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1471,6 +1472,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1806,6 +1808,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1866,6 +1869,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1978,6 +1982,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2043,6 +2048,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2109,6 +2115,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2199,6 +2206,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2283,6 +2291,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2347,6 +2356,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2367,6 +2377,7 @@ mod tests { }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2441,6 +2452,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2461,6 +2473,7 @@ mod tests { }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2530,6 +2543,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2595,6 +2609,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2660,6 +2675,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2680,6 +2696,7 @@ mod tests { }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2728,6 +2745,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2748,6 +2766,7 @@ mod tests { }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2789,6 +2808,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3042,6 +3062,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3098,6 +3119,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3156,6 +3178,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3204,6 +3227,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 3c45c20b8fc2..f83674d4c37d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -209,20 +209,6 @@ pub struct UserSavedConfig { pub model_reasoning_summary: Option, pub model_verbosity: Option, pub tools: Option, - pub profile: Option, - pub profiles: HashMap, -} - -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct Profile { - pub model: Option, - pub model_provider: Option, - pub approval_policy: Option, - pub model_reasoning_effort: Option, - pub model_reasoning_summary: Option, - pub model_verbosity: Option, - pub chatgpt_base_url: Option, } #[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] 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 efb4a26f603e..796c93d55505 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -224,7 +224,7 @@ pub struct GetAccountParams { /// In 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`. - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub refresh_token: bool, } 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 16f9bf154b18..fffe21381130 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -133,30 +133,6 @@ pub struct ToolsV2 { pub web_search: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct ProfileV2 { - pub model: Option, - pub model_provider: Option, - #[experimental(nested)] - pub approval_policy: Option, - /// [UNSTABLE] Optional profile-level override for where approval requests - /// are routed for review. If omitted, the enclosing config default is - /// used. - #[experimental("config/read.approvalsReviewer")] - pub approvals_reviewer: Option, - pub service_tier: Option, - pub model_reasoning_effort: Option, - pub model_reasoning_summary: Option, - pub model_verbosity: Option, - pub web_search: Option, - pub tools: Option, - pub chatgpt_base_url: Option, - #[serde(default, flatten)] - pub additional: HashMap, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] @@ -266,10 +242,6 @@ pub struct Config { pub forced_login_method: Option, pub web_search: Option, pub tools: Option, - pub profile: Option, - #[experimental(nested)] - #[serde(default)] - pub profiles: HashMap, pub instructions: Option, pub developer_instructions: Option, pub compact_prompt: Option, @@ -357,7 +329,7 @@ pub enum ConfigWriteErrorCode { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ConfigReadParams { - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub include_layers: bool, /// Optional working directory to resolve project config layers. If specified, /// return the effective config as seen from that directory (i.e., including any @@ -389,6 +361,7 @@ pub struct ConfigRequirements { pub allowed_permissions: Option>, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, + pub allow_appshots: Option, pub computer_use: Option, pub feature_requirements: Option>, #[experimental("configRequirements/read.hooks")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs b/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs index aaf966a4bfc6..fdddb3aaa7ae 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs @@ -14,6 +14,7 @@ pub struct FeedbackUploadParams { pub reason: Option, #[ts(optional = nullable)] pub thread_id: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub include_logs: bool, #[ts(optional = nullable)] pub extra_log_files: Option>, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index bbe6bec3f2d5..8f6824f397b4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1505,32 +1505,6 @@ fn ask_for_approval_granular_is_marked_experimental() { ); } -#[test] -fn profile_v2_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&ProfileV2 { - model: None, - model_provider: None, - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: true, - mcp_elicitations: false, - }), - approvals_reviewer: None, - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("askForApproval.granular")); -} - #[test] fn config_granular_approval_policy_is_marked_experimental() { let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { @@ -1554,8 +1528,6 @@ fn config_granular_approval_policy_is_marked_experimental() { forced_login_method: None, web_search: None, tools: None, - profile: None, - profiles: HashMap::new(), instructions: None, developer_instructions: None, compact_prompt: None, @@ -1589,116 +1561,6 @@ fn config_approvals_reviewer_is_marked_experimental() { forced_login_method: None, web_search: None, tools: None, - profile: None, - profiles: HashMap::new(), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - desktop: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("config/read.approvalsReviewer")); -} - -#[test] -fn config_nested_profile_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_auto_compact_token_limit_scope: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::from([( - "default".to_string(), - ProfileV2 { - model: None, - model_provider: None, - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - approvals_reviewer: None, - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }, - )]), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - desktop: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("askForApproval.granular")); -} - -#[test] -fn config_nested_profile_approvals_reviewer_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_auto_compact_token_limit_scope: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::from([( - "default".to_string(), - ProfileV2 { - model: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }, - )]), instructions: None, developer_instructions: None, compact_prompt: None, @@ -1731,6 +1593,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() allowed_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 01d8bc2a6da3..4971d5f4b8a7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -162,13 +162,13 @@ pub struct ThreadStartParams { /// If true, opt into emitting raw Responses API items on the event stream. /// This is for internal use only (e.g. Codex Cloud). #[experimental("thread/start.experimentalRawEvents")] - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub experimental_raw_events: bool, /// Deprecated and ignored by app-server. Kept only so older clients can /// continue sending the field while rollout persistence always uses the /// limited history policy. #[experimental("thread/start.persistFullHistory")] - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub persist_extended_history: bool, } @@ -401,7 +401,7 @@ pub struct ThreadResumeParams { /// continue sending the field while rollout persistence always uses the /// limited history policy. #[experimental("thread/resume.persistFullHistory")] - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub persist_extended_history: bool, } @@ -518,7 +518,7 @@ pub struct ThreadForkParams { /// continue sending the field while rollout persistence always uses the /// limited history policy. #[experimental("thread/fork.persistFullHistory")] - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub persist_extended_history: bool, } @@ -1132,7 +1132,7 @@ pub enum ThreadActiveFlag { pub struct ThreadReadParams { pub thread_id: String, /// When true, include turns and their items from rollout history. - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub include_turns: bool, } 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 fb1512fedbd1..627907271352 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 @@ -1339,7 +1339,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() { installation_id: TEST_INSTALLATION_ID.to_string(), }, Some(state_db.clone()), - auth_manager, + auth_manager.clone(), transport_event_tx, shutdown_token.clone(), /*app_server_client_name_rx*/ None, @@ -1358,8 +1358,11 @@ async fn remote_control_waits_for_account_id_before_enrolling() { AuthCredentialsStoreMode::File, ) .expect("auth with account id should save"); + auth_manager.reload().await; - let enroll_request = accept_http_request(&listener).await; + let enroll_request = timeout(Duration::from_millis(100), accept_http_request(&listener)) + .await + .expect("auth change should wake remote control before the retry delay"); assert_eq!( enroll_request.request_line, "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" 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 f117aec3b47d..74ef9c2301a1 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 @@ -226,6 +226,7 @@ pub(crate) struct RemoteControlWebsocket { reconnect_attempt: u64, enrollment: Option, auth_recovery: UnauthorizedRecovery, + auth_change_rx: watch::Receiver, client_tracker: Arc>, state: Arc>, server_event_rx: Arc>>, @@ -240,6 +241,12 @@ pub(crate) struct RemoteControlWebsocketConfig { pub(crate) server_name: String, } +pub(super) struct RemoteControlAuthContext<'a> { + auth_manager: &'a Arc, + auth_recovery: &'a mut UnauthorizedRecovery, + auth_change_rx: &'a mut watch::Receiver, +} + enum ConnectOutcome { Connected(Box>>), Disabled, @@ -321,6 +328,7 @@ impl RemoteControlWebsocket { ); let (outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); let auth_recovery = auth_manager.unauthorized_recovery(); + let auth_change_rx = auth_manager.auth_change_receiver(); Self { remote_control_url: config.remote_control_url, @@ -334,6 +342,7 @@ impl RemoteControlWebsocket { reconnect_attempt: 0, enrollment: None, auth_recovery, + auth_change_rx, client_tracker: Arc::new(Mutex::new(client_tracker)), state: Arc::new(Mutex::new(WebsocketState { outbound_buffer, @@ -457,6 +466,11 @@ impl RemoteControlWebsocket { subscribe_cursor: subscribe_cursor.as_deref(), app_server_client_name, }; + let auth_context = RemoteControlAuthContext { + auth_manager: &self.auth_manager, + auth_recovery: &mut self.auth_recovery, + auth_change_rx: &mut self.auth_change_rx, + }; let connect_result = tokio::select! { _ = shutdown_token.cancelled() => return ConnectOutcome::Shutdown, changed = self.enabled_rx.wait_for(|enabled| !*enabled) => { @@ -468,8 +482,7 @@ impl RemoteControlWebsocket { connect_result = connect_remote_control_websocket( &remote_control_target, self.state_db.as_deref(), - &self.auth_manager, - &mut self.auth_recovery, + auth_context, &mut self.enrollment, connect_options, &self.status_publisher, @@ -517,6 +530,14 @@ impl RemoteControlWebsocket { } return ConnectOutcome::Disabled; } + changed = self.auth_change_rx.changed() => { + if changed.is_err() { + return ConnectOutcome::Shutdown; + } + self.auth_recovery = self.auth_manager.unauthorized_recovery(); + self.reconnect_attempt = 0; + info!("retrying app-server remote control websocket after auth changed"); + } _ = tokio::time::sleep(reconnect_delay) => {} } } @@ -1018,8 +1039,7 @@ pub(crate) async fn load_remote_control_auth( pub(super) async fn connect_remote_control_websocket( remote_control_target: &RemoteControlTarget, state_db: Option<&StateRuntime>, - auth_manager: &Arc, - auth_recovery: &mut UnauthorizedRecovery, + auth_context: RemoteControlAuthContext<'_>, enrollment: &mut Option, connect_options: RemoteControlConnectOptions<'_>, status_publisher: &RemoteControlStatusPublisher, @@ -1028,6 +1048,11 @@ pub(super) async fn connect_remote_control_websocket( tungstenite::http::Response<()>, )> { ensure_rustls_crypto_provider(); + let RemoteControlAuthContext { + auth_manager, + auth_recovery, + auth_change_rx, + } = auth_context; let Some(state_db) = state_db else { *enrollment = None; @@ -1098,7 +1123,7 @@ pub(super) async fn connect_remote_control_websocket( Ok(new_enrollment) => new_enrollment, Err(err) if err.kind() == ErrorKind::PermissionDenied - && recover_remote_control_auth(auth_recovery).await => + && recover_remote_control_auth(auth_recovery, auth_change_rx).await => { return Err(io::Error::other(format!( "{err}; retrying after auth recovery" @@ -1172,7 +1197,7 @@ pub(super) async fn connect_remote_control_websocket( tungstenite::Error::Http(response) if matches!(response.status().as_u16(), 401 | 403) => { - if recover_remote_control_auth(auth_recovery).await { + if recover_remote_control_auth(auth_recovery, auth_change_rx).await { return Err(io::Error::other(format!( "remote control websocket auth failed with HTTP {}; retrying after auth recovery", response.status() @@ -1191,15 +1216,25 @@ pub(super) async fn connect_remote_control_websocket( } } -async fn recover_remote_control_auth(auth_recovery: &mut UnauthorizedRecovery) -> bool { +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() @@ -1213,6 +1248,20 @@ async fn recover_remote_control_auth(auth_recovery: &mut UnauthorizedRecovery) - } } +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, @@ -1290,6 +1339,37 @@ mod tests { (RemoteControlStatusPublisher::new(status_tx), status_rx) } + #[test] + fn mark_recovery_auth_change_seen_marks_only_recovery_revision_seen() { + let (auth_change_tx, mut auth_change_rx) = watch::channel(0u64); + let auth_change_revision_before_recovery = *auth_change_rx.borrow(); + auth_change_tx.send_modify(|revision| *revision += 1); + + mark_recovery_auth_change_seen(&mut auth_change_rx, auth_change_revision_before_recovery); + + assert!( + !auth_change_rx + .has_changed() + .expect("auth change watch should remain open") + ); + } + + #[test] + fn mark_recovery_auth_change_seen_preserves_racing_auth_change() { + let (auth_change_tx, mut auth_change_rx) = watch::channel(0u64); + let auth_change_revision_before_recovery = *auth_change_rx.borrow(); + auth_change_tx.send_modify(|revision| *revision += 1); + auth_change_tx.send_modify(|revision| *revision += 1); + + mark_recovery_auth_change_seen(&mut auth_change_rx, auth_change_revision_before_recovery); + + assert!( + auth_change_rx + .has_changed() + .expect("auth change watch should remain open") + ); + } + async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc { StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()) .await @@ -1375,6 +1455,7 @@ mod tests { let state_db = remote_control_state_runtime(&codex_home).await; let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); let mut enrollment = Some(RemoteControlEnrollment { account_id: "account_id".to_string(), environment_id: "env_test".to_string(), @@ -1386,8 +1467,11 @@ mod tests { let err = match connect_remote_control_websocket( &remote_control_target, Some(state_db.as_ref()), - &auth_manager, - &mut auth_recovery, + RemoteControlAuthContext { + auth_manager: &auth_manager, + auth_recovery: &mut auth_recovery, + auth_change_rx: &mut auth_change_rx, + }, &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, @@ -1440,6 +1524,7 @@ mod tests { ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); let mut enrollment = Some(RemoteControlEnrollment { account_id: "account_id".to_string(), environment_id: "env_test".to_string(), @@ -1466,8 +1551,11 @@ mod tests { let err = connect_remote_control_websocket( &remote_control_target, Some(state_db.as_ref()), - &auth_manager, - &mut auth_recovery, + RemoteControlAuthContext { + auth_manager: &auth_manager, + auth_recovery: &mut auth_recovery, + auth_change_rx: &mut auth_change_rx, + }, &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, @@ -1503,6 +1591,12 @@ mod tests { .expect("token should be readable"), "fresh-token" ); + assert!( + !auth_change_rx + .has_changed() + .expect("auth change watch should remain open"), + "recovery's own auth reload should not wake the reconnect loop" + ); } #[tokio::test] @@ -1538,6 +1632,7 @@ mod tests { ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); let mut enrollment = None; let (status_publisher, status_rx) = remote_control_status_channel(); save_auth( @@ -1550,8 +1645,11 @@ mod tests { let err = connect_remote_control_websocket( &remote_control_target, Some(state_db.as_ref()), - &auth_manager, - &mut auth_recovery, + RemoteControlAuthContext { + auth_manager: &auth_manager, + auth_recovery: &mut auth_recovery, + auth_change_rx: &mut auth_change_rx, + }, &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, @@ -1585,6 +1683,12 @@ mod tests { .expect("token should be readable"), "fresh-token" ); + assert!( + !auth_change_rx + .has_changed() + .expect("auth change watch should remain open"), + "recovery's own auth reload should not wake the reconnect loop" + ); } #[tokio::test] @@ -1593,6 +1697,7 @@ mod tests { .expect("target should parse"); let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); let mut enrollment = Some(RemoteControlEnrollment { account_id: "account_id".to_string(), environment_id: "env_test".to_string(), @@ -1604,8 +1709,11 @@ mod tests { let err = connect_remote_control_websocket( &remote_control_target, /*state_db*/ None, - &auth_manager, - &mut auth_recovery, + RemoteControlAuthContext { + auth_manager: &auth_manager, + auth_recovery: &mut auth_recovery, + auth_change_rx: &mut auth_change_rx, + }, &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, @@ -1637,6 +1745,7 @@ mod tests { ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); let mut enrollment = Some(RemoteControlEnrollment { account_id: "account_id".to_string(), environment_id: "env_test".to_string(), @@ -1653,8 +1762,11 @@ mod tests { let err = connect_remote_control_websocket( &remote_control_target, Some(state_db.as_ref()), - &auth_manager, - &mut auth_recovery, + RemoteControlAuthContext { + auth_manager: &auth_manager, + auth_recovery: &mut auth_recovery, + auth_change_rx: &mut auth_change_rx, + }, &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 2ceffc86fec4..dcc29530736c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1931,7 +1931,7 @@ reason up through the containing type: ```rust #[derive(ExperimentalApi)] -struct ProfileV2 { +struct Config { #[experimental(nested)] approval_policy: Option, } @@ -1950,5 +1950,5 @@ For server-initiated request payloads, annotate the field the same way so schema 5. Verify the protocol crate: ```bash - cargo test -p codex-app-server-protocol + just test -p codex-app-server-protocol ``` diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index efe6c69a32e9..5f784ea23a29 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3219,6 +3219,7 @@ mod tests { "turn-1", &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: Some(42), model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3252,6 +3253,7 @@ mod tests { id: "turn-1".to_string(), msg: EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: Some(42), model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3301,6 +3303,7 @@ mod tests { &event_turn_id, &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: event_turn_id.clone(), + trace_id: None, started_at: Some(42), model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index 4255b83e62a3..d2465d32dd8e 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -125,7 +125,6 @@ impl ConfigManager { }; let effective = layers.effective_config(); - let effective_config_toml: ConfigToml = effective .try_into() .map_err(|err| ConfigManagerError::toml("invalid configuration", err))?; @@ -238,6 +237,23 @@ impl ConfigManager { let segments = parse_key_path(&key_path).map_err(|message| { ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message) })?; + if !value.is_null() { + match segments.as_slice() { + [segment] if segment == "profile" => { + return Err(ConfigManagerError::write( + ConfigWriteErrorCode::ConfigValidationError, + "`profile` is a legacy config selector and can no longer be written; use `--profile ` with `.config.toml` instead", + )); + } + [segment, ..] if segment == "profiles" => { + return Err(ConfigManagerError::write( + ConfigWriteErrorCode::ConfigValidationError, + "`profiles` contains legacy config profile tables and can no longer be written; use `--profile ` with `.config.toml` instead", + )); + } + _ => {} + } + } let original_value = value_at_path(&user_config, &segments).cloned(); let parsed_value = parse_value(value).map_err(|message| { ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message) 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 c1a081e023ce..5c6e3de5a100 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -130,6 +130,112 @@ async fn clear_missing_nested_config_is_noop() -> Result<()> { Ok(()) } +#[tokio::test] +async fn write_value_rejects_legacy_profile_selector() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&path, "model = \"gpt-main\"\n")?; + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(path.display().to_string()), + key_path: "profile".to_string(), + value: serde_json::json!("work"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("legacy profile selector write should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!( + error + .to_string() + .contains("`profile` is a legacy config selector"), + "{error}" + ); + assert_eq!(std::fs::read_to_string(&path)?, "model = \"gpt-main\"\n"); + Ok(()) +} + +#[tokio::test] +async fn write_value_rejects_legacy_profile_table() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&path, "")?; + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(path.display().to_string()), + key_path: "profiles.work.model".to_string(), + value: serde_json::json!("gpt-work"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("legacy profile table write should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!( + error + .to_string() + .contains("`profiles` contains legacy config profile tables"), + "{error}" + ); + assert_eq!(std::fs::read_to_string(&path)?, ""); + Ok(()) +} + +#[tokio::test] +async fn batch_write_rejects_legacy_profile_selector() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&path, "model = \"gpt-main\"\n")?; + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + let error = service + .batch_write(ConfigBatchWriteParams { + edits: vec![ + codex_app_server_protocol::ConfigEdit { + key_path: "model".to_string(), + value: serde_json::json!("gpt-work"), + merge_strategy: MergeStrategy::Replace, + }, + codex_app_server_protocol::ConfigEdit { + key_path: "profile".to_string(), + value: serde_json::json!("work"), + merge_strategy: MergeStrategy::Replace, + }, + ], + file_path: Some(path.display().to_string()), + expected_version: None, + reload_user_config: false, + }) + .await + .expect_err("legacy profile selector batch write should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!( + error + .to_string() + .contains("`profile` is a legacy config selector"), + "{error}" + ); + assert_eq!(std::fs::read_to_string(&path)?, "model = \"gpt-main\"\n"); + Ok(()) +} + #[tokio::test] async fn write_value_supports_nested_app_paths() -> Result<()> { let tmp = tempdir().expect("tempdir"); @@ -638,52 +744,6 @@ async fn write_value_rejects_feature_requirement_conflict() { ); } -#[tokio::test] -async fn write_value_rejects_profile_feature_requirement_conflict() { - let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); - - let service = ConfigManager::new_for_tests( - 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() - })) - }), - ); - - let error = service - .write_value(ConfigValueWriteParams { - file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), - key_path: "profiles.enterprise.features.personality".to_string(), - value: serde_json::json!(false), - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect_err("conflicting profile feature write should fail"); - - assert_eq!( - error.write_error_code(), - Some(ConfigWriteErrorCode::ConfigValidationError) - ); - assert!( - error.to_string().contains( - "invalid value for `features`: `profiles.enterprise.features.personality=false`" - ), - "{error}" - ); - assert_eq!( - std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(), - "" - ); -} - #[tokio::test] async fn read_reports_managed_overrides_user_and_session_flags() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index ef4106aa143d..80305d2d9fc7 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -786,8 +786,7 @@ pub async fn run_main_with_transport_options( }); let processor_handle = tokio::spawn({ - let auth_manager = - AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; + let auth_manager = Arc::clone(&auth_manager); let analytics_events_client = analytics_events_client_from_config(Arc::clone(&auth_manager), &config); let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 8f50a3c216a5..72948270a674 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -431,6 +431,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR normalized }), allow_managed_hooks_only: requirements.allow_managed_hooks_only, + allow_appshots: requirements.allow_appshots, computer_use: requirements .computer_use .map(map_computer_use_requirements_to_api), @@ -658,6 +659,17 @@ mod tests { assert_eq!(mapped.hooks, None); } + #[test] + fn requirements_api_includes_allow_appshots() { + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + allow_appshots: Some(false), + ..ConfigRequirementsToml::default() + }); + + assert_eq!(mapped.allow_appshots, Some(false)); + assert_eq!(mapped.hooks, None); + } + #[test] fn requirements_api_includes_computer_use_requirements() { let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { 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 f4593ab7c43d..c6b5541aa2d6 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 @@ -1155,6 +1155,7 @@ mod thread_processor_behavior_tests { "turn-1", &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs b/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs index 2392cc807842..0b639b7d5c06 100644 --- a/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs +++ b/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs @@ -83,7 +83,6 @@ impl WindowsSandboxRequestProcessor { command_cwd, env_map: std::env::vars().collect(), codex_home: config.codex_home.to_path_buf(), - active_profile: config.active_profile.clone(), }; codex_core::windows_sandbox::run_windows_sandbox_setup(setup_request).await } 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 56c4d0b7d140..0bfd8ec876e6 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -894,19 +894,14 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn config_batch_write_preserves_dotted_profile_names() -> Result<()> { +async fn config_batch_write_rejects_legacy_profile_tables() -> Result<()> { let tmp_dir = TempDir::new()?; let codex_home = tmp_dir.path().canonicalize()?; write_config( &tmp_dir, r#" -profile = "team.prod" - [profiles."team.prod"] model = "gpt-5.3-spark" - -[profiles.team.prod] -model = "should-stay-put" "#, )?; @@ -932,28 +927,30 @@ model = "should-stay-put" reload_user_config: false, }) .await?; - let batch_resp: JSONRPCResponse = timeout( + let err: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(batch_id)), + mcp.read_stream_until_error_message(RequestId::Integer(batch_id)), ) .await??; - let batch_write: ConfigWriteResponse = to_response(batch_resp)?; - assert_eq!(batch_write.status, WriteStatus::Ok); + let code = err + .error + .data + .as_ref() + .and_then(|data| data.get("config_write_error_code")) + .and_then(|value| value.as_str()); + assert_eq!(code, Some("configValidationError")); + assert!( + err.error.message.contains("`profiles`"), + "unexpected error: {err:?}" + ); let config: toml::Value = toml::from_str(&std::fs::read_to_string(codex_home.join("config.toml"))?)?; assert_eq!( config["profiles"]["team.prod"]["model"].as_str(), - Some("gpt-5.5") - ); - assert_eq!( - config["profiles"]["team"]["prod"]["model"].as_str(), - Some("should-stay-put") - ); - assert_eq!( - config["items"]["sample@catalog"]["enabled"].as_bool(), - Some(true) + Some("gpt-5.3-spark") ); + assert_eq!(config.get("items"), None); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index bfb1d4f5e038..e064ff6e254a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -686,7 +686,7 @@ async fn thread_search_returns_content_matches() -> Result<()> { codex_home.path(), "2025-01-02T12-00-00", "2025-01-02T12:00:00Z", - "needle suffix", + "mixed NEEDLE suffix", Some("mock_provider"), /*git_info*/ None, )?; @@ -718,7 +718,7 @@ async fn thread_search_returns_content_matches() -> Result<()> { .map(|result| result.thread.id.as_str()) .collect(); assert_eq!(ids, vec![newer_match, older_match]); - assert_eq!(data[0].snippet, "needle suffix"); + assert_eq!(data[0].snippet, "mixed NEEDLE suffix"); Ok(()) } 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 f21a973f63fd..6416499663aa 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1431,6 +1431,7 @@ async fn thread_resume_token_usage_replay_ignores_stale_interrupted_tail_turn() "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: stale_turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1518,6 +1519,7 @@ async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Re "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: interrupted_turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1835,6 +1837,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index 7ee21a770e49..7c2c17692020 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -20,6 +20,7 @@ codex-linux-sandbox = { workspace = true } codex-sandboxing = { workspace = true } codex-shell-escalation = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-file-lock = { workspace = true } codex-utils-home-dir = { workspace = true } dotenvy = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 2f6ae4653c65..5a558769a7e5 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -6,6 +6,8 @@ use std::path::PathBuf; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; use codex_exec_server::CODEX_FS_HELPER_ARG1; use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; +use codex_utils_file_lock::TryFileLockOutcome; +use codex_utils_file_lock::try_lock_exclusive_optional; use codex_utils_home_dir::find_codex_home; #[cfg(unix)] use std::os::unix::fs::symlink; @@ -33,12 +35,12 @@ pub struct Arg0DispatchPaths { /// Keeps the per-session PATH entry alive and locked for the process lifetime. pub struct Arg0PathEntryGuard { _temp_dir: TempDir, - _lock_file: File, + _lock_file: Option, paths: Arg0DispatchPaths, } impl Arg0PathEntryGuard { - fn new(temp_dir: TempDir, lock_file: File, paths: Arg0DispatchPaths) -> Self { + fn new(temp_dir: TempDir, lock_file: Option, paths: Arg0DispatchPaths) -> Self { Self { _temp_dir: temp_dir, _lock_file: lock_file, @@ -326,7 +328,13 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result Some(lock_file), + TryFileLockOutcome::Unsupported => None, + TryFileLockOutcome::WouldBlock => { + return Err(std::io::Error::from(std::io::ErrorKind::WouldBlock).into()); + } + }; for filename in &[ APPLY_PATCH_ARG0, @@ -445,10 +453,9 @@ fn try_lock_dir(dir: &Path) -> std::io::Result> { Err(err) => return Err(err), }; - match lock_file.try_lock() { - Ok(()) => Ok(Some(lock_file)), - Err(std::fs::TryLockError::WouldBlock) => Ok(None), - Err(err) => Err(err.into()), + match try_lock_exclusive_optional(&lock_file)? { + TryFileLockOutcome::Acquired => Ok(Some(lock_file)), + TryFileLockOutcome::WouldBlock | TryFileLockOutcome::Unsupported => Ok(None), } } @@ -562,7 +569,13 @@ mod tests { let dir = root.path().join("locked"); fs::create_dir(&dir)?; let lock_file = create_lock(&dir)?; - lock_file.try_lock()?; + match try_lock_exclusive_optional(&lock_file)? { + TryFileLockOutcome::Acquired => {} + TryFileLockOutcome::Unsupported => return Ok(()), + TryFileLockOutcome::WouldBlock => { + panic!("newly created lock file should not be locked"); + } + } janitor_cleanup(root.path())?; diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index bc9d6172f249..918d81aad220 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -41,9 +41,11 @@ use seatbelt::DenialLogger; pub async fn run_command_under_seatbelt( command: SeatbeltCommand, codex_linux_sandbox_exe: Option, + loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let SeatbeltCommand { permissions_profile, + config_profile: _, cwd, include_managed_config, allow_unix_sockets, @@ -60,6 +62,7 @@ pub async fn run_command_under_seatbelt( permissions_profile, cwd, managed_requirements_mode, + loader_overrides, }, command, config_overrides, @@ -75,6 +78,7 @@ pub async fn run_command_under_seatbelt( pub async fn run_command_under_seatbelt( _command: SeatbeltCommand, _codex_linux_sandbox_exe: Option, + _loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { anyhow::bail!("Seatbelt sandbox is only available on macOS"); } @@ -82,9 +86,11 @@ pub async fn run_command_under_seatbelt( pub async fn run_command_under_landlock( command: LandlockCommand, codex_linux_sandbox_exe: Option, + loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let LandlockCommand { permissions_profile, + config_profile: _, cwd, include_managed_config, config_overrides, @@ -99,6 +105,7 @@ pub async fn run_command_under_landlock( permissions_profile, cwd, managed_requirements_mode, + loader_overrides, }, command, config_overrides, @@ -110,12 +117,14 @@ pub async fn run_command_under_landlock( .await } -pub async fn run_command_under_windows( +pub async fn run_command_under_windows_sandbox( command: WindowsCommand, codex_linux_sandbox_exe: Option, + loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let WindowsCommand { permissions_profile, + config_profile: _, cwd, include_managed_config, config_overrides, @@ -130,6 +139,7 @@ pub async fn run_command_under_windows( permissions_profile, cwd, managed_requirements_mode, + loader_overrides, }, command, config_overrides, @@ -153,6 +163,7 @@ struct DebugSandboxConfigOptions { permissions_profile: Option, cwd: Option, managed_requirements_mode: ManagedRequirementsMode, + loader_overrides: LoaderOverrides, } #[derive(Debug, Clone, Copy)] @@ -650,7 +661,7 @@ async fn load_debug_sandbox_config( } async fn load_debug_sandbox_config_with_codex_home( - mut cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Vec<(String, TomlValue)>, codex_linux_sandbox_exe: Option, options: DebugSandboxConfigOptions, codex_home: Option, @@ -660,7 +671,9 @@ async fn load_debug_sandbox_config_with_codex_home( permissions_profile, cwd, managed_requirements_mode, + loader_overrides, } = options; + let mut cli_overrides = cli_overrides; if let Some(permissions_profile) = permissions_profile { cli_overrides.push(( @@ -672,10 +685,9 @@ async fn load_debug_sandbox_config_with_codex_home( // For legacy configs, `codex sandbox` historically defaulted to read-only // instead of inheriting ambient `sandbox_mode` settings from user/system // config. Keep that behavior unless this invocation explicitly passes a - // legacy `sandbox_mode` CLI override, which is now the documented writable - // replacement for the removed `--full-auto` flag. + // legacy `sandbox_mode` CLI override for compatibility with older callers. let uses_legacy_sandbox_mode_override = cli_overrides_use_legacy_sandbox_mode(&cli_overrides); - let config = build_debug_sandbox_config( + let config = build_debug_sandbox_config_with_loader_overrides( cli_overrides.clone(), ConfigOverrides { cwd: cwd.clone(), @@ -684,6 +696,7 @@ async fn load_debug_sandbox_config_with_codex_home( }, codex_home.clone(), managed_requirements_mode, + loader_overrides.clone(), strict_config, ) .await?; @@ -692,7 +705,7 @@ async fn load_debug_sandbox_config_with_codex_home( return Ok(config); } - build_debug_sandbox_config( + build_debug_sandbox_config_with_loader_overrides( cli_overrides, ConfigOverrides { sandbox_mode: Some(SandboxMode::ReadOnly), @@ -702,17 +715,19 @@ async fn load_debug_sandbox_config_with_codex_home( }, codex_home, managed_requirements_mode, + loader_overrides, strict_config, ) .await .map_err(Into::into) } -async fn build_debug_sandbox_config( +async fn build_debug_sandbox_config_with_loader_overrides( cli_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, codex_home: Option, managed_requirements_mode: ManagedRequirementsMode, + mut loader_overrides: LoaderOverrides, strict_config: bool, ) -> std::io::Result { let mut builder = ConfigBuilder::default() @@ -720,11 +735,9 @@ async fn build_debug_sandbox_config( .harness_overrides(harness_overrides) .strict_config(strict_config); if matches!(managed_requirements_mode, ManagedRequirementsMode::Ignore) { - builder = builder.loader_overrides(LoaderOverrides { - ignore_managed_requirements: true, - ..LoaderOverrides::default() - }); + loader_overrides.ignore_managed_requirements = true; } + builder = builder.loader_overrides(loader_overrides); if let Some(codex_home) = codex_home { builder = builder .codex_home(codex_home.clone()) @@ -751,6 +764,24 @@ mod tests { use pretty_assertions::assert_eq; use tempfile::TempDir; + async fn build_debug_sandbox_config( + cli_overrides: Vec<(String, TomlValue)>, + harness_overrides: ConfigOverrides, + codex_home: Option, + managed_requirements_mode: ManagedRequirementsMode, + strict_config: bool, + ) -> std::io::Result { + build_debug_sandbox_config_with_loader_overrides( + cli_overrides, + harness_overrides, + codex_home, + managed_requirements_mode, + LoaderOverrides::default(), + strict_config, + ) + .await + } + fn escape_toml_path(path: &std::path::Path) -> String { path.display().to_string().replace('\\', "\\\\") } @@ -759,6 +790,18 @@ mod tests { codex_home: &TempDir, docs: &std::path::Path, private: &std::path::Path, + ) -> std::io::Result<()> { + write_permissions_profile_config_to_path( + &codex_home.path().join("config.toml"), + docs, + private, + ) + } + + fn write_permissions_profile_config_to_path( + config_path: &std::path::Path, + docs: &std::path::Path, + private: &std::path::Path, ) -> std::io::Result<()> { std::fs::create_dir_all(private)?; let config = format!( @@ -773,7 +816,7 @@ mod tests { escape_toml_path(docs), escape_toml_path(private), ); - std::fs::write(codex_home.path().join("config.toml"), config)?; + std::fs::write(config_path, config)?; Ok(()) } @@ -813,6 +856,7 @@ mod tests { permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home_path), /*strict_config*/ false, @@ -837,6 +881,70 @@ mod tests { Ok(()) } + #[tokio::test] + async fn debug_sandbox_honors_config_profile_loader_overrides() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let sandbox_paths = TempDir::new()?; + let docs = sandbox_paths.path().join("docs"); + let private = docs.join("private"); + let profile_path = codex_home.path().join("work.config.toml"); + write_permissions_profile_config_to_path(&profile_path, &docs, &private)?; + let codex_home_path = codex_home.path().to_path_buf(); + let loader_overrides = LoaderOverrides { + user_config_path: Some(AbsolutePathBuf::from_absolute_path(&profile_path)?), + user_config_profile: Some("work".parse().expect("profile name should parse")), + ..LoaderOverrides::default() + }; + + let profile_config = build_debug_sandbox_config_with_loader_overrides( + Vec::new(), + ConfigOverrides::default(), + Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, + loader_overrides.clone(), + /*strict_config*/ false, + ) + .await?; + let read_only_config = build_debug_sandbox_config( + Vec::new(), + ConfigOverrides { + sandbox_mode: Some(SandboxMode::ReadOnly), + ..Default::default() + }, + Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, + /*strict_config*/ false, + ) + .await?; + + let config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + /*codex_linux_sandbox_exe*/ None, + DebugSandboxConfigOptions { + permissions_profile: None, + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides, + }, + Some(codex_home_path), + /*strict_config*/ false, + ) + .await?; + + assert!(config_uses_permission_profiles(&config)); + assert_ne!( + profile_config.permissions.file_system_sandbox_policy(), + read_only_config.permissions.file_system_sandbox_policy(), + "test fixture should distinguish the profile config from read-only" + ); + assert_eq!( + config.permissions.file_system_sandbox_policy(), + profile_config.permissions.file_system_sandbox_policy(), + ); + + Ok(()) + } + #[tokio::test] async fn debug_sandbox_honors_explicit_legacy_sandbox_mode() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -873,6 +981,7 @@ mod tests { permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home_path), /*strict_config*/ false, @@ -930,6 +1039,7 @@ mod tests { permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home_path), /*strict_config*/ false, @@ -956,48 +1066,7 @@ mod tests { permissions_profile: Some(":workspace".to_string()), cwd: None, managed_requirements_mode: ManagedRequirementsMode::Ignore, - }, - Some(codex_home.path().to_path_buf()), - /*strict_config*/ false, - ) - .await?; - - let actual = config - .permissions - .permission_profile() - .file_system_sandbox_policy(); - let expected = codex_protocol::models::PermissionProfile::workspace_write() - .file_system_sandbox_policy(); - assert!( - expected - .entries - .iter() - .all(|entry| actual.entries.contains(entry)), - "explicit workspace profile should preserve the built-in workspace rules" - ); - - Ok(()) - } - - #[tokio::test] - async fn explicit_permission_profile_overrides_active_profile_sandbox_mode() - -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join("config.toml"), - "profile = \"legacy\"\n\ - \n\ - [profiles.legacy]\n\ - sandbox_mode = \"danger-full-access\"\n", - )?; - - let config = load_debug_sandbox_config_with_codex_home( - Vec::new(), - /*codex_linux_sandbox_exe*/ None, - DebugSandboxConfigOptions { - permissions_profile: Some(":workspace".to_string()), - cwd: None, - managed_requirements_mode: ManagedRequirementsMode::Ignore, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home.path().to_path_buf()), /*strict_config*/ false, @@ -1036,6 +1105,7 @@ mod tests { permissions_profile: Some("limited-read-test".to_string()), cwd: None, managed_requirements_mode: ManagedRequirementsMode::Ignore, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home.path().to_path_buf()), /*strict_config*/ false, @@ -1074,6 +1144,7 @@ mod tests { permissions_profile: Some(":workspace".to_string()), cwd: Some(cwd.path().to_path_buf()), managed_requirements_mode: ManagedRequirementsMode::Ignore, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home.path().to_path_buf()), /*strict_config*/ false, diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index 5bea8ce78dc2..5e2ba0caa5b9 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -5,11 +5,12 @@ pub(crate) mod login; use clap::Parser; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; +use codex_utils_cli::ProfileV2Name; use std::path::PathBuf; pub use debug_sandbox::run_command_under_landlock; pub use debug_sandbox::run_command_under_seatbelt; -pub use debug_sandbox::run_command_under_windows; +pub use debug_sandbox::run_command_under_windows_sandbox; pub use login::read_access_token_from_stdin; pub use login::read_api_key_from_stdin; pub use login::run_login_status; @@ -20,14 +21,18 @@ pub use login::run_login_with_device_code; pub use login::run_login_with_device_code_fallback_to_browser; pub use login::run_logout; -// TODO: Deduplicate these shared sandbox options if we remove the explicit -// `codex sandbox ` platform subcommands. +// These command structs share common sandbox options, but remain separate +// because each host backend has a slightly different option surface. #[derive(Debug, Parser)] pub struct SeatbeltCommand { /// Named permissions profile to apply from the active configuration stack. #[arg(long = "permissions-profile", value_name = "NAME")] pub permissions_profile: Option, + /// Layer $CODEX_HOME/.config.toml on top of the base user config. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + /// Working directory used for profile resolution and command execution. #[arg( short = 'C', @@ -72,6 +77,10 @@ pub struct LandlockCommand { #[arg(long = "permissions-profile", value_name = "NAME")] pub permissions_profile: Option, + /// Layer $CODEX_HOME/.config.toml on top of the base user config. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + /// Working directory used for profile resolution and command execution. #[arg( short = 'C', @@ -103,6 +112,10 @@ pub struct WindowsCommand { #[arg(long = "permissions-profile", value_name = "NAME")] pub permissions_profile: Option, + /// Layer $CODEX_HOME/.config.toml on top of the base user config. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + /// Working directory used for profile resolution and command execution. #[arg( short = 'C', diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 4382dee29224..4bb7ef74a023 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -10,9 +10,6 @@ use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_chatgpt::apply_command::ApplyCommand; use codex_chatgpt::apply_command::run_apply_command; -use codex_cli::LandlockCommand; -use codex_cli::SeatbeltCommand; -use codex_cli::WindowsCommand; use codex_cli::read_access_token_from_stdin; use codex_cli::read_api_key_from_stdin; use codex_cli::run_login_status; @@ -160,7 +157,7 @@ enum Subcommand { Doctor(DoctorCommand), /// Run commands within a Codex-provided sandbox. - Sandbox(SandboxArgs), + Sandbox(HostSandboxArgs), /// Debugging tools. Debug(DebugCommand), @@ -343,24 +340,29 @@ struct ForkCommand { config_overrides: TuiCli, } -#[derive(Debug, Parser)] -struct SandboxArgs { - #[command(subcommand)] - cmd: SandboxCommand, -} +#[cfg(target_os = "macos")] +type HostSandboxArgs = codex_cli::SeatbeltCommand; +#[cfg(target_os = "linux")] +type HostSandboxArgs = codex_cli::LandlockCommand; +#[cfg(target_os = "windows")] +type HostSandboxArgs = codex_cli::WindowsCommand; -#[derive(Debug, clap::Subcommand)] -enum SandboxCommand { - /// Run a command under Seatbelt (macOS only). - #[clap(visible_alias = "seatbelt")] - Macos(SeatbeltCommand), +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +type HostSandboxArgs = UnsupportedSandboxArgs; - /// Run a command under the Linux sandbox (bubblewrap by default). - #[clap(visible_alias = "landlock")] - Linux(LandlockCommand), +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +#[derive(Debug, Parser)] +struct UnsupportedSandboxArgs { + /// Layer $CODEX_HOME/.config.toml on top of the base user config. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + + #[clap(skip)] + pub config_overrides: CliConfigOverrides, - /// Run a command under Windows restricted token (Windows only). - Windows(WindowsCommand), + /// Full command args to run under the host sandbox. + #[arg(trailing_var_arg = true)] + pub command: Vec, } #[derive(Debug, Parser)] @@ -918,7 +920,9 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; // Propagate any root-level config overrides (e.g. `-c key=value`). prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone()); - mcp_cli.run().await?; + let loader_overrides = + loader_overrides_for_profile(interactive.config_profile_v2.as_ref())?; + mcp_cli.run(loader_overrides).await?; } Some(Subcommand::Plugin(plugin_cli)) => { reject_remote_mode_for_subcommand( @@ -1236,56 +1240,48 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_cloud_tasks::run_main(cloud_cli, arg0_paths.codex_linux_sandbox_exe.clone()) .await?; } - Some(Subcommand::Sandbox(sandbox_args)) => match sandbox_args.cmd { - SandboxCommand::Macos(mut seatbelt_cli) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "sandbox macos", - )?; - prepend_config_flags( - &mut seatbelt_cli.config_overrides, - root_config_overrides.clone(), - ); - codex_cli::run_command_under_seatbelt( - seatbelt_cli, - arg0_paths.codex_linux_sandbox_exe.clone(), - ) - .await?; - } - SandboxCommand::Linux(mut landlock_cli) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "sandbox linux", - )?; - prepend_config_flags( - &mut landlock_cli.config_overrides, - root_config_overrides.clone(), - ); - codex_cli::run_command_under_landlock( - landlock_cli, - arg0_paths.codex_linux_sandbox_exe.clone(), - ) - .await?; - } - SandboxCommand::Windows(mut windows_cli) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "sandbox windows", - )?; - prepend_config_flags( - &mut windows_cli.config_overrides, - root_config_overrides.clone(), - ); - codex_cli::run_command_under_windows( - windows_cli, - arg0_paths.codex_linux_sandbox_exe.clone(), - ) - .await?; + Some(Subcommand::Sandbox(mut sandbox_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "sandbox", + )?; + let config_profile = sandbox_cli + .config_profile + .as_ref() + .or(interactive.config_profile_v2.as_ref()); + let loader_overrides = loader_overrides_for_profile(config_profile)?; + prepend_config_flags( + &mut sandbox_cli.config_overrides, + root_config_overrides.clone(), + ); + #[cfg(target_os = "macos")] + codex_cli::run_command_under_seatbelt( + sandbox_cli, + arg0_paths.codex_linux_sandbox_exe.clone(), + loader_overrides, + ) + .await?; + #[cfg(target_os = "linux")] + codex_cli::run_command_under_landlock( + sandbox_cli, + arg0_paths.codex_linux_sandbox_exe.clone(), + loader_overrides, + ) + .await?; + #[cfg(target_os = "windows")] + codex_cli::run_command_under_windows_sandbox( + sandbox_cli, + arg0_paths.codex_linux_sandbox_exe.clone(), + loader_overrides, + ) + .await?; + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = loader_overrides; + anyhow::bail!("`codex sandbox` is not supported on this operating system"); } - }, + } Some(Subcommand::Debug(DebugCommand { subcommand })) => match subcommand { DebugSubcommand::Models(cmd) => { reject_remote_mode_for_subcommand( @@ -1459,11 +1455,13 @@ fn profile_v2_for_subcommand<'a>( | Subcommand::Review(_) | Subcommand::Resume(_) | Subcommand::Fork(_) + | Subcommand::Mcp(_) + | Subcommand::Sandbox(_) | Subcommand::Debug(DebugCommand { subcommand: DebugSubcommand::PromptInput(_), }) => Ok(Some(profile_v2)), _ => anyhow::bail!( - "--profile only applies to runtime commands: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, and `codex debug prompt-input`." + "--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`." ), } } @@ -2264,6 +2262,18 @@ mod tests { .as_deref(), Some("work") ); + assert_eq!( + profile_v2_for_args(&["codex", "--profile", "work", "mcp", "list"]) + .expect("mcp supports profile-v2") + .as_deref(), + Some("work") + ); + assert_eq!( + profile_v2_for_args(&["codex", "--profile", "work", "sandbox"]) + .expect("sandbox supports config profile") + .as_deref(), + Some("work") + ); } #[test] @@ -2491,12 +2501,12 @@ mod tests { assert!(matches!(cli.subcommand, Some(Subcommand::Update))); } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] #[test] - fn sandbox_macos_parses_permissions_profile() { + fn sandbox_parses_permissions_profile() { let cli = MultitoolCli::try_parse_from([ "codex", "sandbox", - "macos", "--permissions-profile", ":workspace", "--", @@ -2504,20 +2514,33 @@ mod tests { ]) .expect("parse"); - let Some(Subcommand::Sandbox(SandboxArgs { - cmd: SandboxCommand::Macos(command), - })) = cli.subcommand - else { - panic!("expected sandbox macos command"); + let Some(Subcommand::Sandbox(command)) = cli.subcommand else { + panic!("expected sandbox command"); }; assert_eq!(command.permissions_profile.as_deref(), Some(":workspace")); assert_eq!(command.command, vec!["echo"]); } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] + #[test] + fn sandbox_parses_config_profile() { + let cli = + MultitoolCli::try_parse_from(["codex", "sandbox", "--profile", "work", "--", "echo"]) + .expect("parse"); + + let Some(Subcommand::Sandbox(command)) = cli.subcommand else { + panic!("expected sandbox command"); + }; + + assert_eq!(command.config_profile.as_deref(), Some("work")); + assert_eq!(command.command, vec!["echo"]); + } + + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] #[test] - fn sandbox_macos_rejects_explicit_profile_controls_without_profile() { - let err = MultitoolCli::try_parse_from(["codex", "sandbox", "macos", "-C", "/tmp"]) + fn sandbox_rejects_explicit_profile_controls_without_profile() { + let err = MultitoolCli::try_parse_from(["codex", "sandbox", "-C", "/tmp"]) .expect_err("parse should fail"); assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); @@ -2570,8 +2593,7 @@ mod tests { #[test] fn sandbox_full_auto_no_longer_parses() { - let result = - MultitoolCli::try_parse_from(["codex", "sandbox", "linux", "--full-auto", "--"]); + let result = MultitoolCli::try_parse_from(["codex", "sandbox", "--full-auto", "--"]); assert!(result.is_err()); } diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index f52d24160ba9..a84d1c90198f 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -8,9 +8,12 @@ use anyhow::bail; use clap::ArgGroup; use codex_config::types::AppToolApproval; use codex_config::types::McpServerConfig; +use codex_config::types::McpServerOAuthConfig; use codex_config::types::McpServerTransportConfig; use codex_core::McpManager; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; +use codex_core::config::LoaderOverrides; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::find_codex_home; use codex_core::config::load_global_mcp_servers; @@ -132,6 +135,14 @@ pub struct AddMcpStreamableHttpArgs { requires = "url" )] pub bearer_token_env_var: Option, + + /// Optional OAuth client identifier to use for this MCP server. + #[arg(long = "oauth-client-id", value_name = "CLIENT_ID", requires = "url")] + pub oauth_client_id: Option, + + /// Optional OAuth resource parameter to include during MCP login. + #[arg(long = "oauth-resource", value_name = "RESOURCE", requires = "url")] + pub oauth_resource: Option, } #[derive(Debug, clap::Parser)] @@ -157,12 +168,16 @@ pub struct LogoutArgs { } impl McpCli { - pub async fn run(self) -> Result<()> { + pub async fn run(self, loader_overrides: LoaderOverrides) -> Result<()> { let McpCli { config_overrides, subcommand, } = self; + if loader_overrides.user_config_profile.is_some() { + validate_profile_v2_migration(&config_overrides, loader_overrides).await?; + } + match subcommand { McpSubcommand::List(args) => { run_list(&config_overrides, args).await?; @@ -239,6 +254,22 @@ async fn perform_oauth_login_retry_without_scopes( } } +async fn validate_profile_v2_migration( + config_overrides: &CliConfigOverrides, + loader_overrides: LoaderOverrides, +) -> Result<()> { + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + ConfigBuilder::default() + .cli_overrides(overrides) + .loader_overrides(loader_overrides) + .build() + .await + .context("failed to load configuration")?; + Ok(()) +} + async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<()> { // Validate any provided overrides even though they are not currently applied. let overrides = config_overrides @@ -260,7 +291,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re .await .with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?; - let transport = match transport_args { + let (transport, oauth_client_id, oauth_resource) = match transport_args { AddMcpTransportArgs { stdio: Some(stdio), .. } => { @@ -275,27 +306,37 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re } else { Some(stdio.env.into_iter().collect::>()) }; - McpServerTransportConfig::Stdio { - command: command_bin, - args: command_args, - env: env_map, - env_vars: Vec::new(), - cwd: None, - } + ( + McpServerTransportConfig::Stdio { + command: command_bin, + args: command_args, + env: env_map, + env_vars: Vec::new(), + cwd: None, + }, + None, + None, + ) } AddMcpTransportArgs { streamable_http: Some(AddMcpStreamableHttpArgs { url, bearer_token_env_var, + oauth_client_id, + oauth_resource, }), .. - } => McpServerTransportConfig::StreamableHttp { - url, - bearer_token_env_var, - http_headers: None, - env_http_headers: None, - }, + } => ( + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers: None, + env_http_headers: None, + }, + oauth_client_id, + oauth_resource, + ), AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"), }; @@ -312,8 +353,12 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re enabled_tools: None, disabled_tools: None, scopes: None, - oauth: None, - oauth_resource: None, + oauth: oauth_client_id + .clone() + .map(|client_id| McpServerOAuthConfig { + client_id: Some(client_id), + }), + oauth_resource: oauth_resource.clone(), tools: HashMap::new(), }; @@ -342,8 +387,8 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re oauth_config.http_headers, oauth_config.env_http_headers, &resolved_scopes, - /*oauth_client_id*/ None, - /*oauth_resource*/ None, + oauth_client_id.as_deref(), + oauth_resource.as_deref(), config.mcp_oauth_callback_port, config.mcp_oauth_callback_url.as_deref(), ) diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index 15afaf0828f4..d0fc5f327db2 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -68,6 +68,28 @@ async fn add_and_remove_server_updates_global_config() -> Result<()> { Ok(()) } +#[tokio::test] +async fn profile_mcp_reports_legacy_profile_migration() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[profiles.work] +model = "gpt-5" +"#, + )?; + + let mut list_cmd = codex_command(codex_home.path())?; + list_cmd + .args(["--profile", "work", "mcp", "list"]) + .assert() + .failure() + .stderr(contains("--profile `work` cannot be used")) + .stderr(contains("[profiles.work]")) + .stderr(contains("work.config.toml")); + + Ok(()) +} + #[tokio::test] async fn add_with_env_preserves_key_order_and_values() -> Result<()> { let codex_home = TempDir::new()?; @@ -176,6 +198,42 @@ async fn add_streamable_http_with_custom_env_var() -> Result<()> { Ok(()) } +#[tokio::test] +async fn add_streamable_http_with_oauth_options() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add_cmd = codex_command(codex_home.path())?; + add_cmd + .args([ + "mcp", + "add", + "oauth-server", + "--url", + "https://example.com/mcp", + "--oauth-client-id", + "eci-prd-pub-codex-123", + "--oauth-resource", + "https://resource.example.com", + ]) + .assert() + .success(); + + let servers = load_global_mcp_servers(codex_home.path()).await?; + let oauth_server = servers + .get("oauth-server") + .expect("oauth server should exist"); + assert_eq!( + oauth_server.oauth_client_id(), + Some("eci-prd-pub-codex-123") + ); + assert_eq!( + oauth_server.oauth_resource.as_deref(), + Some("https://resource.example.com") + ); + + Ok(()) +} + #[tokio::test] async fn add_streamable_http_rejects_removed_flag() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 27649ad4c1b4..19fbd8cd1d93 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -1220,6 +1220,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1305,6 +1306,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1341,6 +1343,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1394,6 +1397,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1576,6 +1580,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1659,6 +1664,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1740,6 +1746,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1949,6 +1956,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -1992,6 +2000,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2055,6 +2064,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2114,6 +2124,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2175,6 +2186,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2237,6 +2249,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2303,6 +2316,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2395,6 +2409,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, @@ -2433,6 +2448,7 @@ command = "sample-mcp" remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index 401dfab3a928..1c34d8bbf235 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -2,6 +2,7 @@ use crate::TransportError; use crate::error::ApiError; use crate::rate_limits::parse_promo_message; use crate::rate_limits::parse_rate_limit_for_limit; +use crate::rate_limits::parse_rate_limit_reached_type; use base64::Engine; use chrono::DateTime; use chrono::Utc; @@ -85,6 +86,8 @@ pub fn map_api_error(err: ApiError) -> CodexErr { parse_rate_limit_for_limit(map, limit_id.as_deref()) }); let promo_message = headers.as_ref().and_then(parse_promo_message); + let rate_limit_reached_type = + headers.as_ref().and_then(parse_rate_limit_reached_type); let resets_at = err .error .resets_at @@ -94,6 +97,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr { resets_at, rate_limits: rate_limits.map(Box::new), promo_message, + rate_limit_reached_type, }); } else if err.error.error_type.as_deref() == Some("usage_not_included") { return CodexErr::UsageNotIncluded; diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index af7e34a6492f..101e5566fe22 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -194,6 +194,37 @@ fn map_api_error_does_not_fallback_limit_name_to_limit_id() { ); } +#[test] +fn map_api_error_ignores_unparseable_rate_limit_reached_type_headers() { + let values = [ + http::HeaderValue::from_static("future_rate_limit_reached_type"), + http::HeaderValue::from_bytes(&[0xff]).expect("valid opaque header value"), + ]; + + for value in values { + let mut headers = HeaderMap::new(); + headers.insert("x-codex-rate-limit-reached-type", value); + let body = serde_json::json!({ + "error": { + "type": "usage_limit_reached", + "plan_type": "pro", + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::TOO_MANY_REQUESTS, + url: Some("http://example.com/v1/responses".to_string()), + headers: Some(headers), + body: Some(body), + })); + + let CodexErr::UsageLimitReached(usage_limit) = err else { + panic!("expected CodexErr::UsageLimitReached, got {err:?}"); + }; + assert_eq!(usage_limit.rate_limit_reached_type, None); + } +} + #[test] fn map_api_error_extracts_identity_auth_details_from_headers() { let mut headers = HeaderMap::new(); diff --git a/codex-rs/codex-api/src/endpoint/images.rs b/codex-rs/codex-api/src/endpoint/images.rs new file mode 100644 index 000000000000..9d1bd41eea3c --- /dev/null +++ b/codex-rs/codex-api/src/endpoint/images.rs @@ -0,0 +1,302 @@ +use crate::auth::SharedAuthProvider; +use crate::endpoint::session::EndpointSession; +use crate::error::ApiError; +use crate::images::ImageEditRequest; +use crate::images::ImageGenerationRequest; +use crate::images::ImageResponse; +use crate::provider::Provider; +use codex_client::HttpTransport; +use codex_client::RequestTelemetry; +use http::HeaderMap; +use http::Method; +use serde::Serialize; +use serde_json::to_value; +use std::sync::Arc; + +pub struct ImagesClient { + session: EndpointSession, +} + +impl ImagesClient { + pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self { + Self { + session: EndpointSession::new(transport, provider, auth), + } + } + + pub fn with_telemetry(self, request: Option>) -> Self { + Self { + session: self.session.with_request_telemetry(request), + } + } + + pub async fn generate( + &self, + request: &ImageGenerationRequest, + extra_headers: HeaderMap, + ) -> Result { + self.post_image_request( + "images/generations", + request, + extra_headers, + "image generation", + ) + .await + } + + pub async fn edit( + &self, + request: &ImageEditRequest, + extra_headers: HeaderMap, + ) -> Result { + self.post_image_request("images/edits", request, extra_headers, "image edit") + .await + } + + async fn post_image_request( + &self, + path: &str, + request: &R, + extra_headers: HeaderMap, + operation: &str, + ) -> Result { + let body = to_value(request) + .map_err(|e| ApiError::Stream(format!("failed to encode {operation} request: {e}")))?; + let resp = self + .session + .execute(Method::POST, path, extra_headers, Some(body)) + .await?; + serde_json::from_slice(&resp.body) + .map_err(|e| ApiError::Stream(format!("failed to decode {operation} response: {e}"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::AuthProvider; + use crate::images::ImageBackground; + use crate::images::ImageData; + use crate::images::ImageQuality; + use crate::images::ImageUrl; + use crate::provider::RetryConfig; + use async_trait::async_trait; + use codex_client::Request; + use codex_client::RequestBody; + use codex_client::Response; + use codex_client::StreamResponse; + use codex_client::TransportError; + use http::StatusCode; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::sync::Mutex; + use std::time::Duration; + + #[derive(Clone, Default)] + struct DummyAuth; + + impl AuthProvider for DummyAuth { + fn add_auth_headers(&self, _headers: &mut HeaderMap) {} + } + + #[derive(Clone)] + struct CapturingTransport { + last_request: Arc>>, + response_body: Arc>, + } + + impl CapturingTransport { + fn new(response_body: Vec) -> Self { + Self { + last_request: Arc::new(Mutex::new(None)), + response_body: Arc::new(response_body), + } + } + } + + #[async_trait] + impl HttpTransport for CapturingTransport { + async fn execute(&self, req: Request) -> Result { + *self.last_request.lock().expect("lock request store") = Some(req); + Ok(Response { + status: StatusCode::OK, + headers: HeaderMap::new(), + body: self.response_body.as_ref().clone().into(), + }) + } + + async fn stream(&self, _req: Request) -> Result { + Err(TransportError::Build("stream should not run".to_string())) + } + } + + fn provider() -> Provider { + Provider { + name: "test".to_string(), + base_url: "https://example.com/api/codex".to_string(), + query_params: None, + headers: HeaderMap::new(), + retry: RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: true, + retry_transport: true, + }, + stream_idle_timeout: Duration::from_secs(1), + } + } + + fn response_body() -> Vec { + serde_json::to_vec(&json!({ + "created": 1778832973u64, + "background": "opaque", + "data": [{"b64_json": "REDACT"}], + "output_format": "png", + "quality": "medium", + "size": "1024x1536", + "usage": { + "input_tokens": 1474, + "input_tokens_details": { + "image_tokens": 1457, + "text_tokens": 17, + }, + "output_tokens": 1372, + "output_tokens_details": { + "image_tokens": 1372, + "text_tokens": 0, + }, + "total_tokens": 2846, + } + })) + .expect("serialize response") + } + + fn expected_response() -> ImageResponse { + ImageResponse { + created: 1778832973, + background: Some(ImageBackground::Opaque), + data: vec![ImageData { + b64_json: "REDACT".to_string(), + }], + quality: Some(ImageQuality::Medium), + size: Some("1024x1536".to_string()), + } + } + + fn captured_request(transport: &CapturingTransport) -> Request { + transport + .last_request + .lock() + .expect("lock request store") + .clone() + .expect("request should be captured") + } + + #[tokio::test] + async fn generate_posts_typed_request_and_parses_image_response() { + let transport = CapturingTransport::new(response_body()); + let client = ImagesClient::new(transport.clone(), provider(), Arc::new(DummyAuth)); + + let response = client + .generate( + &ImageGenerationRequest { + prompt: "a red fox in a field".to_string(), + background: Some(ImageBackground::Opaque), + model: "gpt-image-1.5".to_string(), + n: None, + quality: Some(ImageQuality::Medium), + size: Some("1024x1536".to_string()), + }, + HeaderMap::new(), + ) + .await + .expect("image generation request should succeed"); + + assert_eq!(response, expected_response()); + + let request = captured_request(&transport); + assert_eq!( + request.url, + "https://example.com/api/codex/images/generations" + ); + assert_eq!( + request.body.as_ref().and_then(RequestBody::json), + Some(&json!({ + "prompt": "a red fox in a field", + "background": "opaque", + "model": "gpt-image-1.5", + "quality": "medium", + "size": "1024x1536", + })) + ); + } + + #[tokio::test] + async fn edit_posts_typed_request_and_parses_image_response() { + let transport = CapturingTransport::new(response_body()); + let client = ImagesClient::new(transport.clone(), provider(), Arc::new(DummyAuth)); + + let response = client + .edit( + &ImageEditRequest { + images: vec![ImageUrl { + image_url: "data:image/png;base64,Zm9v".to_string(), + }], + prompt: "add a red hat".to_string(), + background: None, + model: "gpt-image-1.5".to_string(), + n: None, + quality: None, + size: None, + }, + HeaderMap::new(), + ) + .await + .expect("image edit request should succeed"); + + assert_eq!(response, expected_response()); + + let request = captured_request(&transport); + assert_eq!(request.url, "https://example.com/api/codex/images/edits"); + assert_eq!( + request.body.as_ref().and_then(RequestBody::json), + Some(&json!({ + "images": [{"image_url": "data:image/png;base64,Zm9v"}], + "prompt": "add a red hat", + "model": "gpt-image-1.5", + })) + ); + } + + #[tokio::test] + async fn image_response_requires_image_data() { + let transport = CapturingTransport::new( + serde_json::to_vec(&json!({"created": 1778832973u64})).expect("serialize response"), + ); + let client = ImagesClient::new(transport, provider(), Arc::new(DummyAuth)); + + let error = client + .generate( + &ImageGenerationRequest { + prompt: "a red fox in a field".to_string(), + background: None, + model: "gpt-image-1.5".to_string(), + n: None, + quality: None, + size: None, + }, + HeaderMap::new(), + ) + .await + .expect_err("image response without data should fail"); + + let ApiError::Stream(message) = error else { + panic!("expected image response decode error"); + }; + assert!( + message.starts_with("failed to decode image generation response: missing field `data`"), + "{message}" + ); + } +} diff --git a/codex-rs/codex-api/src/endpoint/mod.rs b/codex-rs/codex-api/src/endpoint/mod.rs index 21ebf372a174..106c5d73ff2e 100644 --- a/codex-rs/codex-api/src/endpoint/mod.rs +++ b/codex-rs/codex-api/src/endpoint/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod compact; +pub(crate) mod images; pub(crate) mod memories; pub(crate) mod models; pub(crate) mod realtime_call; @@ -9,6 +10,7 @@ pub(crate) mod search; mod session; pub use compact::CompactClient; +pub use images::ImagesClient; pub use memories::MemoriesClient; pub use models::ModelsClient; pub use realtime_call::RealtimeCallClient; diff --git a/codex-rs/codex-api/src/images.rs b/codex-rs/codex-api/src/images.rs new file mode 100644 index 000000000000..f915a5f78d22 --- /dev/null +++ b/codex-rs/codex-api/src/images.rs @@ -0,0 +1,70 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ImageGenerationRequest { + pub prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub n: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ImageEditRequest { + pub images: Vec, + pub prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub n: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ImageUrl { + pub image_url: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ImageBackground { + Transparent, + Opaque, + Auto, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ImageQuality { + Low, + Medium, + High, + Auto, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ImageResponse { + pub created: u64, + pub data: Vec, + #[serde(default)] + pub background: Option, + #[serde(default)] + pub quality: Option, + #[serde(default)] + pub size: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ImageData { + pub b64_json: String, +} diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index f47791b799b2..08176d8dec65 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -4,6 +4,7 @@ pub(crate) mod common; pub(crate) mod endpoint; pub(crate) mod error; pub(crate) mod files; +pub(crate) mod images; pub(crate) mod provider; pub(crate) mod rate_limits; pub(crate) mod requests; @@ -41,6 +42,7 @@ pub use crate::common::WS_REQUEST_HEADER_TRACESTATE_CLIENT_METADATA_KEY; pub use crate::common::create_text_param_for_request; pub use crate::common::response_create_client_metadata; pub use crate::endpoint::CompactClient; +pub use crate::endpoint::ImagesClient; pub use crate::endpoint::MemoriesClient; pub use crate::endpoint::ModelsClient; pub use crate::endpoint::RealtimeCallClient; @@ -63,6 +65,13 @@ pub use crate::endpoint::SearchClient; pub use crate::endpoint::session_update_session_json; pub use crate::error::ApiError; pub use crate::files::upload_local_file; +pub use crate::images::ImageBackground; +pub use crate::images::ImageData; +pub use crate::images::ImageEditRequest; +pub use crate::images::ImageGenerationRequest; +pub use crate::images::ImageQuality; +pub use crate::images::ImageResponse; +pub use crate::images::ImageUrl; pub use crate::provider::Provider; pub use crate::provider::RetryConfig; pub use crate::provider::is_azure_responses_provider; diff --git a/codex-rs/codex-api/src/rate_limits.rs b/codex-rs/codex-api/src/rate_limits.rs index 979500cdabc4..a2ad876671cf 100644 --- a/codex-rs/codex-api/src/rate_limits.rs +++ b/codex-rs/codex-api/src/rate_limits.rs @@ -1,5 +1,6 @@ use codex_protocol::account::PlanType; use codex_protocol::protocol::CreditsSnapshot; +use codex_protocol::protocol::RateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use http::HeaderMap; @@ -178,6 +179,13 @@ pub fn parse_promo_message(headers: &HeaderMap) -> Option { .map(std::string::ToString::to_string) } +pub(crate) fn parse_rate_limit_reached_type(headers: &HeaderMap) -> Option { + parse_header_str(headers, "x-codex-rate-limit-reached-type")? + .trim() + .parse() + .ok() +} + fn parse_rate_limit_window( headers: &HeaderMap, used_percent_header: &str, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index d209c161999e..93d16d3ccfa2 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -89,6 +89,7 @@ pub struct ConfigRequirements { pub permission_profile: ConstrainedWithSource, pub web_search_mode: ConstrainedWithSource, pub allow_managed_hooks_only: Option>, + pub allow_appshots: Option>, pub computer_use: Option>, pub feature_requirements: Option>, pub managed_hooks: Option>, @@ -124,6 +125,7 @@ impl Default for ConfigRequirements { /*source*/ None, ), allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, managed_hooks: None, @@ -750,6 +752,7 @@ pub struct ConfigRequirementsToml { pub remote_sandbox_config: Option>, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, + pub allow_appshots: Option, pub computer_use: Option, #[serde(rename = "features", alias = "feature_requirements")] pub feature_requirements: Option, @@ -801,6 +804,7 @@ pub struct ConfigRequirementsWithSources { pub allowed_permissions: Option>>, pub allowed_web_search_modes: Option>>, pub allow_managed_hooks_only: Option>, + pub allow_appshots: Option>, pub computer_use: Option>, pub feature_requirements: Option>, pub hooks: Option>, @@ -840,6 +844,7 @@ impl ConfigRequirementsWithSources { remote_sandbox_config: _, allowed_web_search_modes: _, allow_managed_hooks_only: _, + allow_appshots: _, computer_use: _, feature_requirements: _, hooks: _, @@ -872,6 +877,7 @@ impl ConfigRequirementsWithSources { allowed_permissions, allowed_web_search_modes, allow_managed_hooks_only, + allow_appshots, computer_use, feature_requirements, hooks, @@ -902,6 +908,7 @@ impl ConfigRequirementsWithSources { allowed_permissions, allowed_web_search_modes, allow_managed_hooks_only, + allow_appshots, computer_use, feature_requirements, hooks, @@ -922,6 +929,7 @@ impl ConfigRequirementsWithSources { remote_sandbox_config: None, allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value), + allow_appshots: allow_appshots.map(|sourced| sourced.value), computer_use: computer_use.map(|sourced| sourced.value), feature_requirements: feature_requirements.map(|sourced| sourced.value), hooks: hooks.map(|sourced| sourced.value), @@ -1008,6 +1016,7 @@ impl ConfigRequirementsToml { && self.remote_sandbox_config.is_none() && self.allowed_web_search_modes.is_none() && self.allow_managed_hooks_only.is_none() + && self.allow_appshots.is_none() && self .computer_use .as_ref() @@ -1054,6 +1063,7 @@ impl TryFrom for ConfigRequirements { allowed_permissions: _, allowed_web_search_modes, allow_managed_hooks_only, + allow_appshots, computer_use, feature_requirements, hooks, @@ -1291,6 +1301,7 @@ impl TryFrom for ConfigRequirements { permission_profile, web_search_mode, allow_managed_hooks_only, + allow_appshots, computer_use, feature_requirements, managed_hooks, @@ -1361,6 +1372,7 @@ mod tests { remote_sandbox_config: _, allowed_web_search_modes, allow_managed_hooks_only, + allow_appshots, computer_use, feature_requirements, hooks, @@ -1386,6 +1398,8 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), allow_managed_hooks_only: allow_managed_hooks_only .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allow_appshots: allow_appshots + .map(|value| Sourced::new(value, RequirementSource::Unknown)), computer_use: computer_use.map(|value| Sourced::new(value, RequirementSource::Unknown)), feature_requirements: feature_requirements .map(|value| Sourced::new(value, RequirementSource::Unknown)), @@ -1466,6 +1480,19 @@ mod tests { Ok(()) } + #[test] + fn deserialize_allow_appshots() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_appshots = true + "#, + )?; + + assert_eq!(requirements.allow_appshots, Some(true)); + assert!(!requirements.is_empty()); + Ok(()) + } + #[test] fn filesystem_requirements_table_cannot_define_a_permission_profile() { let err = from_str::( @@ -1484,6 +1511,19 @@ mod tests { ); } + #[test] + fn allow_appshots_false_is_still_configured() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_appshots = false + "#, + )?; + + assert_eq!(requirements.allow_appshots, Some(false)); + assert!(!requirements.is_empty()); + Ok(()) + } + #[test] fn deserialize_computer_use_requirements() -> Result<()> { let requirements: ConfigRequirementsToml = from_str( @@ -1539,6 +1579,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: Some(allowed_web_search_modes.clone()), allow_managed_hooks_only: Some(true), + allow_appshots: Some(false), computer_use: Some(computer_use.clone()), feature_requirements: Some(feature_requirements.clone()), hooks: None, @@ -1578,6 +1619,7 @@ mod tests { /*value*/ true, enforce_source.clone(), )), + allow_appshots: Some(Sourced::new(/*value*/ false, enforce_source.clone(),)), computer_use: Some(Sourced::new(computer_use, enforce_source.clone())), feature_requirements: Some(Sourced::new( feature_requirements, @@ -1623,6 +1665,7 @@ mod tests { allowed_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, @@ -1673,6 +1716,7 @@ mod tests { allowed_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index a4d384b5f39e..b0e2a0d45c6f 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -27,9 +27,6 @@ use crate::types::ToolSuggestConfig; use crate::types::Tui; use crate::types::UriBasedFileOpener; use crate::types::WindowsToml; -use codex_app_server_protocol::ForcedChatgptWorkspaceIds as ApiForcedChatgptWorkspaceIds; -use codex_app_server_protocol::Tools; -use codex_app_server_protocol::UserSavedConfig; use codex_features::FeaturesToml; use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; @@ -105,13 +102,6 @@ impl ForcedChatgptWorkspaceIds { Self::Multiple(values) => values, } } - - pub fn into_api(self) -> ApiForcedChatgptWorkspaceIds { - match self { - Self::Single(value) => ApiForcedChatgptWorkspaceIds::Single(value), - Self::Multiple(values) => ApiForcedChatgptWorkspaceIds::Multiple(values), - } - } } impl<'de> Deserialize<'de> for ForcedChatgptWorkspaceIds { @@ -319,7 +309,8 @@ pub struct ConfigToml { /// Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses `$CODEX_HOME`. pub sqlite_home: Option, - /// Directory where Codex writes log files, for example `codex-tui.log`. + /// Directory where Codex writes log files. Setting this value explicitly + /// also enables the TUI text log in this directory. /// Defaults to `$CODEX_HOME/log`. pub log_dir: Option, @@ -552,33 +543,6 @@ pub struct AutoReviewToml { pub policy: Option, } -impl From for UserSavedConfig { - fn from(config_toml: ConfigToml) -> Self { - let profiles = config_toml - .profiles - .into_iter() - .map(|(k, v)| (k, v.into())) - .collect(); - - Self { - approval_policy: config_toml.approval_policy, - sandbox_mode: config_toml.sandbox_mode, - sandbox_settings: config_toml.sandbox_workspace_write.map(From::from), - forced_chatgpt_workspace_id: config_toml - .forced_chatgpt_workspace_id - .map(ForcedChatgptWorkspaceIds::into_api), - forced_login_method: config_toml.forced_login_method, - model: config_toml.model, - model_reasoning_effort: config_toml.model_reasoning_effort, - model_reasoning_summary: config_toml.model_reasoning_summary, - model_verbosity: config_toml.model_verbosity, - tools: config_toml.tools.map(From::from), - profile: config_toml.profile, - profiles, - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct ProjectConfig { @@ -729,14 +693,6 @@ pub struct AgentRoleToml { pub nickname_candidates: Option>, } -impl From for Tools { - fn from(tools_toml: ToolsToml) -> Self { - Self { - web_search: tools_toml.web_search.is_some().then_some(true), - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct GhostSnapshotToml { diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 528dedaba841..e8972ce5ad66 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -225,21 +225,26 @@ pub async fn load_config_layers_state( ) .await?; if let Some(active_user_profile) = active_user_profile.as_ref() - && base_user_layer.config.as_table().is_some_and(|config| { - config - .get("profiles") - .and_then(TomlValue::as_table) - .is_some_and(|profiles| profiles.contains_key(active_user_profile.as_str())) - }) + && let Some(base_user_config) = base_user_layer.config.as_table() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "--profile `{active_user_profile}` cannot be used while {} contains legacy `[profiles.{active_user_profile}]` config; move those settings into {} or remove `[profiles.{active_user_profile}]`. See https://developers.openai.com/codex/config-advanced#profiles for more information.", - base_user_file.as_path().display(), - active_user_file.as_path().display() - ), - )); + let legacy_profile_is_selected = base_user_config + .get("profile") + .and_then(TomlValue::as_str) + .is_some_and(|profile| profile == active_user_profile.as_str()); + let legacy_profile_table_exists = base_user_config + .get("profiles") + .and_then(TomlValue::as_table) + .is_some_and(|profiles| profiles.contains_key(active_user_profile.as_str())); + if legacy_profile_is_selected || legacy_profile_table_exists { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "--profile `{active_user_profile}` cannot be used while {} contains legacy `profile = \"{active_user_profile}\"` or `[profiles.{active_user_profile}]` config; move those settings into {} and remove the legacy profile selector/table. See https://developers.openai.com/codex/config-advanced#profiles for more information.", + base_user_file.as_path().display(), + active_user_file.as_path().display() + ), + )); + } } layers.push(base_user_layer); diff --git a/codex-rs/config/src/loader/tests.rs b/codex-rs/config/src/loader/tests.rs index 812a86e259be..2c87e1381d10 100644 --- a/codex-rs/config/src/loader/tests.rs +++ b/codex-rs/config/src/loader/tests.rs @@ -137,6 +137,61 @@ model = "gpt-work" ); } +#[tokio::test] +async fn profile_v2_rejects_matching_legacy_profile_selector_in_base_user_config() { + let tmp = tempdir().expect("tempdir"); + let selected_config = tmp.path().join("work.config.toml"); + + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + r#" +profile = "work" +model = "gpt-main" +"#, + ) + .expect("write default user config"); + std::fs::write(&selected_config, r#"model = "gpt-work-v2""#) + .expect("write selected user config"); + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.user_config_path = Some(AbsolutePathBuf::resolve_path_against_base( + "work.config.toml", + tmp.path(), + )); + overrides.user_config_profile = Some("work".parse().expect("profile-v2 name")); + + let err = load_config_layers_state( + &TestFileSystem, + tmp.path(), + /*cwd*/ None, + &[], + overrides, + CloudRequirementsLoader::default(), + &crate::NoopThreadConfigLoader, + ) + .await + .expect_err("profile-v2 should reject a matching legacy profile selector"); + + assert_eq!( + err.kind(), + io::ErrorKind::InvalidData, + "a matching legacy profile selector should be a hard config error" + ); + let message = err.to_string(); + assert!( + message.contains("--profile `work` cannot be used"), + "unexpected error message: {message}" + ); + assert!( + message.contains("profile = \"work\""), + "unexpected error message: {message}" + ); + assert!( + message.contains("work.config.toml"), + "unexpected error message: {message}" + ); +} + #[tokio::test] async fn profile_v2_allows_unrelated_legacy_profiles_in_base_user_config() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index 5f4c8d62f910..e7cddd3d679f 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -81,17 +81,3 @@ pub struct ProfileTui { #[serde(default)] pub session_picker_view: Option, } - -impl From for codex_app_server_protocol::Profile { - fn from(config_profile: ConfigProfile) -> Self { - Self { - model: config_profile.model, - model_provider: config_profile.model_provider, - approval_policy: config_profile.approval_policy, - model_reasoning_effort: config_profile.model_reasoning_effort, - model_reasoning_summary: config_profile.model_reasoning_summary, - model_verbosity: config_profile.model_verbosity, - chatgpt_base_url: config_profile.chatgpt_base_url, - } - } -} diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index be6c8aca62ca..9e0ef7c471ec 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -655,6 +655,7 @@ pub async fn load_plugin_skills( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some(plugin_id.as_key()), + plugin_root: Some(plugin_root.clone()), }) .collect::>(); let outcome = load_skills_from_roots(roots).await; diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index 2473f7108cf9..00d1cbba1421 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -30,6 +30,7 @@ use std::error::Error; use std::fmt; use std::io; use std::path::Component; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use toml::Value as TomlValue; @@ -154,6 +155,7 @@ pub struct SkillRoot { pub scope: SkillScope, pub file_system: Arc, pub plugin_id: Option, + pub plugin_root: Option, } pub async fn load_skills_from_roots(roots: I) -> SkillLoadOutcome @@ -174,6 +176,7 @@ where &root_path, root.scope, root.plugin_id.as_deref(), + root.plugin_root.as_ref(), &mut outcome, ) .await; @@ -258,6 +261,7 @@ async fn skill_roots_with_home_dir( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some(root.plugin_id), + plugin_root: Some(root.plugin_root), })); roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await); dedupe_skill_roots_by_path(&mut roots); @@ -287,6 +291,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::Repo, file_system: Arc::clone(repo_fs), plugin_id: None, + plugin_root: None, }); } } @@ -298,6 +303,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }); // `$HOME/.agents/skills` (user-installed skills). @@ -307,6 +313,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }); } @@ -317,6 +324,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::System, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }); } ConfigLayerSource::System { .. } => { @@ -327,6 +335,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::Admin, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }); } ConfigLayerSource::Mdm { .. } @@ -359,6 +368,7 @@ async fn repo_agents_skill_roots( scope: SkillScope::Repo, file_system: Arc::clone(&fs), plugin_id: None, + plugin_root: None, }), Ok(_) => {} Err(err) if err.kind() == io::ErrorKind::NotFound => {} @@ -458,9 +468,11 @@ async fn discover_skills_under_root( root: &AbsolutePathBuf, scope: SkillScope, plugin_id: Option<&str>, + plugin_root: Option<&AbsolutePathBuf>, outcome: &mut SkillLoadOutcome, ) { let root = canonicalize_for_skill_identity(root); + let plugin_root = plugin_root.map(canonicalize_for_skill_identity); match fs.get_metadata(&root, /*sandbox*/ None).await { Ok(metadata) if metadata.is_directory => {} @@ -570,7 +582,7 @@ async fn discover_skills_under_root( } if metadata.is_file && file_name == SKILLS_FILENAME { - match parse_skill_file(fs, &path, scope, plugin_id).await { + match parse_skill_file(fs, &path, scope, plugin_id, plugin_root.as_ref()).await { Ok(skill) => { outcome.skills.push(skill); } @@ -601,6 +613,7 @@ async fn parse_skill_file( path: &AbsolutePathBuf, scope: SkillScope, plugin_id: Option<&str>, + plugin_root: Option<&AbsolutePathBuf>, ) -> Result { let contents = fs .read_file_text(path, /*sandbox*/ None) @@ -634,7 +647,7 @@ async fn parse_skill_file( interface, dependencies, policy, - } = load_skill_metadata(fs, path).await; + } = load_skill_metadata(fs, path, plugin_root).await; validate_len(&name, MAX_NAME_LEN, "name")?; validate_len(&description, MAX_DESCRIPTION_LEN, "description")?; @@ -687,6 +700,7 @@ async fn namespaced_skill_name( async fn load_skill_metadata( fs: &dyn ExecutorFileSystem, skill_path: &AbsolutePathBuf, + plugin_root: Option<&AbsolutePathBuf>, ) -> LoadedSkillMetadata { // Fail open: optional metadata should not block loading SKILL.md. let Some(skill_dir) = skill_path.parent() else { @@ -744,7 +758,7 @@ async fn load_skill_metadata( policy, } = parsed; LoadedSkillMetadata { - interface: resolve_interface(interface, &skill_dir), + interface: resolve_interface(interface, &skill_dir, plugin_root), dependencies: resolve_dependencies(dependencies), policy: resolve_policy(policy), } @@ -753,6 +767,7 @@ async fn load_skill_metadata( fn resolve_interface( interface: Option, skill_dir: &AbsolutePathBuf, + plugin_root: Option<&AbsolutePathBuf>, ) -> Option { let interface = interface?; let interface = SkillInterface { @@ -766,8 +781,18 @@ fn resolve_interface( MAX_SHORT_DESCRIPTION_LEN, "interface.short_description", ), - icon_small: resolve_asset_path(skill_dir, "interface.icon_small", interface.icon_small), - icon_large: resolve_asset_path(skill_dir, "interface.icon_large", interface.icon_large), + icon_small: resolve_asset_path( + skill_dir, + plugin_root, + "interface.icon_small", + interface.icon_small, + ), + icon_large: resolve_asset_path( + skill_dir, + plugin_root, + "interface.icon_large", + interface.icon_large, + ), brand_color: resolve_color_str(interface.brand_color, "interface.brand_color"), default_prompt: resolve_str( interface.default_prompt, @@ -845,10 +870,12 @@ fn resolve_dependency_tool(tool: DependencyTool) -> Option fn resolve_asset_path( skill_dir: &AbsolutePathBuf, + plugin_root: Option<&AbsolutePathBuf>, field: &'static str, path: Option, ) -> Option { - // Icons must be relative paths under the skill's assets/ directory; otherwise return None. + // Icons must stay under the skill's assets directory. Plugin skills may + // also share icons from the plugin-level assets directory. let path = path?; if path.as_os_str().is_empty() { return None; @@ -869,8 +896,7 @@ fn resolve_asset_path( Component::CurDir => {} Component::Normal(component) => normalized.push(component), Component::ParentDir => { - tracing::warn!("ignoring {field}: icon path must not contain '..'"); - return None; + return resolve_plugin_shared_asset_path(skill_dir, plugin_root, field, &path); } _ => { tracing::warn!("ignoring {field}: icon path must be under assets/"); @@ -891,6 +917,48 @@ fn resolve_asset_path( Some(skill_dir.join(normalized)) } +fn resolve_plugin_shared_asset_path( + skill_dir: &AbsolutePathBuf, + plugin_root: Option<&AbsolutePathBuf>, + field: &'static str, + path: &Path, +) -> Option { + let Some(plugin_root) = plugin_root else { + tracing::warn!("ignoring {field}: icon path must not contain '..'"); + return None; + }; + + let plugin_assets_dir = lexically_normalize(plugin_root.join("assets").as_path()); + let resolved = lexically_normalize(skill_dir.join(path).as_path()); + if !resolved.starts_with(&plugin_assets_dir) { + tracing::warn!("ignoring {field}: icon path with '..' must resolve under plugin assets/"); + return None; + } + + AbsolutePathBuf::try_from(resolved) + .map_err(|err| { + tracing::warn!("ignoring {field}: icon path must resolve to an absolute path: {err}"); + err + }) + .ok() +} + +fn lexically_normalize(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Prefix(_) | Component::RootDir | Component::Normal(_) => { + normalized.push(component.as_os_str()); + } + } + } + normalized +} + fn sanitize_single_line(raw: &str) -> String { raw.split_whitespace().collect::>().join(" ") } diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index a1d03dead2e7..84867ca66bfd 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -822,6 +822,116 @@ async fn drops_interface_when_icons_are_invalid() { ); } +#[tokio::test] +async fn loads_plugin_skill_interface_icons_from_shared_plugin_assets() { + let root = tempfile::tempdir().expect("tempdir"); + let plugin_root = root.path().join("plugins/twilio-developer-kit"); + let skill_path = write_skill_at( + &plugin_root.join("skills"), + "twilio-send-message", + "send-message", + "send messages", + ); + let skill_dir = skill_path.parent().expect("skill dir"); + fs::create_dir_all(plugin_root.join("assets")).unwrap(); + fs::write(plugin_root.join("assets/logo.svg"), "").unwrap(); + write_skill_interface_at( + skill_dir, + r##" +interface: + icon_small: "../../assets/logo.svg" + icon_large: "../../assets/logo.svg" +"##, + ); + + let plugin_root_abs = plugin_root.abs(); + 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("twilio-developer-kit@test".to_string()), + plugin_root: Some(plugin_root_abs.clone()), + }]) + .await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + let expected_icon_path = normalized(&plugin_root.join("assets/logo.svg")); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "send-message".to_string(), + description: "send messages".to_string(), + short_description: None, + interface: Some(SkillInterface { + display_name: None, + short_description: None, + icon_small: Some(expected_icon_path.clone()), + icon_large: Some(expected_icon_path), + brand_color: None, + default_prompt: None, + }), + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: Some("twilio-developer-kit@test".to_string()), + }] + ); +} + +#[tokio::test] +async fn drops_plugin_skill_interface_icons_that_escape_shared_plugin_assets() { + let root = tempfile::tempdir().expect("tempdir"); + let plugin_root = root.path().join("plugins/twilio-developer-kit"); + let skill_path = write_skill_at( + &plugin_root.join("skills"), + "twilio-send-message", + "send-message", + "send messages", + ); + let skill_dir = skill_path.parent().expect("skill dir"); + write_skill_interface_at( + skill_dir, + r##" +interface: + icon_small: "../../other/logo.svg" +"##, + ); + + 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("twilio-developer-kit@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: "send-message".to_string(), + description: "send messages".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: Some("twilio-developer-kit@test".to_string()), + }] + ); +} + #[cfg(unix)] fn symlink_dir(target: &Path, link: &Path) { std::os::unix::fs::symlink(target, link).unwrap(); @@ -943,6 +1053,7 @@ async fn loads_skills_via_symlinked_subdir_for_admin_scope() { scope: SkillScope::Admin, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }]) .await; @@ -1024,6 +1135,7 @@ async fn system_scope_ignores_symlinked_subdir() { scope: SkillScope::System, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }]) .await; assert!( @@ -1057,6 +1169,7 @@ async fn respects_max_scan_depth_for_user_scope() { scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }]) .await; @@ -1163,6 +1276,7 @@ async fn namespaces_plugin_skills_using_plugin_name() { scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some("sample@test".to_string()), + plugin_root: Some(plugin_root.abs()), }]) .await; @@ -1485,12 +1599,14 @@ async fn deduplicates_by_path_preferring_first_root() { scope: SkillScope::Repo, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }, SkillRoot { path: root.path().abs(), scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }, ]) .await; diff --git a/codex-rs/core-skills/src/manager_tests.rs b/codex-rs/core-skills/src/manager_tests.rs index 15b06b393a48..4afa83e21ef7 100644 --- a/codex-rs/core-skills/src/manager_tests.rs +++ b/codex-rs/core-skills/src/manager_tests.rs @@ -15,6 +15,7 @@ use codex_utils_plugins::PluginSkillRoot; use pretty_assertions::assert_eq; use std::collections::HashSet; use std::fs; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use tempfile::TempDir; @@ -54,6 +55,21 @@ fn write_plugin_skill( skill_path } +fn plugin_skill_root_for_skill_path(skill_path: &Path, plugin_id: &str) -> PluginSkillRoot { + let skills_root = skill_path + .parent() + .and_then(Path::parent) + .expect("plugin skill should live under a skills root"); + let plugin_root = skills_root + .parent() + .expect("plugin skills root should live under a plugin root"); + PluginSkillRoot { + path: skills_root.abs(), + plugin_id: plugin_id.to_string(), + plugin_root: plugin_root.abs(), + } +} + fn test_skill(name: &str, path: PathBuf) -> SkillMetadata { SkillMetadata { name: name.to_string(), @@ -146,18 +162,11 @@ async fn skills_for_config_with_stack( skills_manager: &SkillsManager, cwd: &TempDir, config_layer_stack: &ConfigLayerStack, - effective_skill_roots: &[AbsolutePathBuf], + effective_skill_roots: &[PluginSkillRoot], ) -> SkillLoadOutcome { let skills_input = SkillsLoadInput::new( cwd.path().abs(), - effective_skill_roots - .iter() - .cloned() - .map(|path| PluginSkillRoot { - path, - plugin_id: "test-plugin@test".to_string(), - }) - .collect(), + effective_skill_roots.to_vec(), config_layer_stack.clone(), bundled_skills_enabled_from_stack(config_layer_stack), ); @@ -228,11 +237,7 @@ async fn skills_for_config_disables_plugin_skills_by_name() { &codex_home, &name_toggle_config("sample:sample-search", /*enabled*/ false), ); - let plugin_skill_root = skill_path - .parent() - .and_then(std::path::Path::parent) - .expect("plugin skill should live under a skills root") - .abs(); + let plugin_skill_root = plugin_skill_root_for_skill_path(&skill_path, "test-plugin@test"); let skills_manager = SkillsManager::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 8472918dcae9..fdf1d4b18a9e 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -63,6 +63,7 @@ codex-thread-store = { workspace = true } codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cache = { workspace = true } +codex-utils-file-lock = { workspace = true } codex-utils-image = { workspace = true } codex-utils-home-dir = { workspace = true } codex-utils-output-truncation = { workspace = true } @@ -125,6 +126,10 @@ openssl-sys = { workspace = true, features = ["vendored"] } [target.aarch64-unknown-linux-musl.dependencies] openssl-sys = { workspace = true, features = ["vendored"] } +# Build OpenSSL from source for Android builds. +[target.aarch64-linux-android.dependencies] +openssl-sys = { workspace = true, features = ["vendored"] } + [target.'cfg(unix)'.dependencies] codex-shell-escalation = { workspace = true } diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index 3283ba2c3e4b..57b9e53f6b51 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -22,7 +22,7 @@ Seatbelt also keeps the legacy default preferences read access ### Linux -Expects the binary containing `codex-core` to run the equivalent of `codex sandbox linux` (legacy alias: `codex debug landlock`) when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details. +Expects the binary containing `codex-core` to run the equivalent of `codex sandbox` when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details. Legacy `SandboxPolicy` / `sandbox_mode` configs are still supported on Linux. They can continue to use the legacy Landlock path when the split filesystem diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6f85785f2eee..7d95eec379a2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4654,7 +4654,7 @@ "$ref": "#/definitions/AbsolutePathBuf" } ], - "description": "Directory where Codex writes log files, for example `codex-tui.log`. Defaults to `$CODEX_HOME/log`." + "description": "Directory where Codex writes log files. Setting this value explicitly also enables the TUI text log in this directory. Defaults to `$CODEX_HOME/log`." }, "marketplaces": { "additionalProperties": { diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 42981a5d0a21..a9a90be54aa0 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -1165,24 +1165,16 @@ impl AgentControl { let state = self.upgrade()?; let mut children_by_parent = HashMap::>::new(); - for thread_id in state.list_thread_ids().await { - let Ok(thread) = state.get_thread(thread_id).await else { - continue; - }; - let snapshot = thread.config_snapshot().await; - let Some(parent_thread_id) = thread_spawn_parent_thread_id(&snapshot.session_source) - else { - continue; - }; + for (parent_thread_id, child_thread_id) in state.list_live_thread_spawn_edges().await { children_by_parent .entry(parent_thread_id) .or_default() .push(( - thread_id, + child_thread_id, self.state - .agent_metadata_for_thread(thread_id) + .agent_metadata_for_thread(child_thread_id) .unwrap_or(AgentMetadata { - agent_id: Some(thread_id), + agent_id: Some(child_thread_id), ..Default::default() }), )); diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 7ff50ffef837..ed39df1548a7 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -271,6 +271,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, @@ -2123,6 +2124,64 @@ async fn list_agent_subtree_thread_ids_includes_anonymous_and_closed_descendants ); } +#[tokio::test] +async fn list_agent_subtree_thread_ids_includes_live_descendants_without_state_db() { + let (_home, config) = test_config().await; + let manager = ThreadManager::with_models_provider_home_and_state_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + /*state_db*/ None, + ); + let control = manager.agent_control(); + let parent_thread_id = manager + .start_thread(config.clone()) + .await + .expect("parent should start") + .thread_id; + + let child_thread_id = control + .spawn_agent( + config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + let grandchild_thread_id = control + .spawn_agent( + config, + text_input("hello grandchild"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: child_thread_id, + depth: 2, + agent_path: None, + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("grandchild spawn should succeed"); + + let mut subtree_thread_ids = manager + .list_agent_subtree_thread_ids(parent_thread_id) + .await + .expect("live subtree should load"); + subtree_thread_ids.sort_by_key(ToString::to_string); + let mut expected_subtree_thread_ids = + vec![parent_thread_id, child_thread_id, grandchild_thread_id]; + expected_subtree_thread_ids.sort_by_key(ToString::to_string); + + assert_eq!(subtree_thread_ids, expected_subtree_thread_ids); +} + #[tokio::test] async fn shutdown_agent_tree_closes_live_descendants() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index a38930c00e8b..6d49c0557394 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -2,9 +2,9 @@ //! //! Roles are selected at spawn time and are loaded with the same config machinery as //! `config.toml`. This module resolves built-in and user-defined role files, inserts the role as a -//! high-precedence layer, and preserves the caller's current profile/provider unless the role -//! explicitly takes ownership of model selection. It does not decide when to spawn a sub-agent or -//! which role to use; the multi-agent tool handler owns that orchestration. +//! high-precedence layer, and preserves the caller's current provider and service tier unless the +//! role layer sets them. It does not decide when to spawn a sub-agent or which role to use; the +//! multi-agent tool handler owns that orchestration. use crate::config::AgentRoleConfig; use crate::config::Config; @@ -29,14 +29,12 @@ use toml::Value as TomlValue; pub const DEFAULT_ROLE_NAME: &str = "default"; const AGENT_TYPE_UNAVAILABLE_ERROR: &str = "agent type is currently not available"; -/// Applies a named role layer to `config` while preserving caller-owned model selection. +/// Applies a named role layer to `config` while preserving caller-owned provider settings. /// /// The role layer is inserted at session-flag precedence so it can override persisted config, but -/// the caller's current `profile` and `model_provider` remain sticky runtime choices unless the -/// role explicitly sets `profile`, explicitly sets `model_provider`, or rewrites the active -/// profile's `model_provider` in place. Rebuilding the config without those overrides would make a -/// spawned agent silently fall back to the default provider, which is the bug this preservation -/// logic avoids. +/// the caller's current `model_provider` and `service_tier` remain sticky runtime choices unless +/// the role explicitly sets the corresponding top-level config key. Rebuilding the config without +/// those overrides would make a spawned agent silently fall back to default settings. pub(crate) async fn apply_role_to_config( config: &mut Config, role_name: Option<&str>, @@ -71,14 +69,12 @@ async fn apply_role_to_config_inner( { return Ok(()); } - let (preserve_current_profile, preserve_current_provider) = - preservation_policy(config, &role_layer_toml); + let preserve_current_provider = role_layer_toml.get("model_provider").is_none(); let preserve_current_service_tier = role_layer_toml.get("service_tier").is_none(); *config = reload::build_next_config( config, role_layer_toml, - preserve_current_profile, preserve_current_provider, preserve_current_service_tier, ) @@ -130,48 +126,19 @@ pub(crate) fn resolve_role_config<'a>( .or_else(|| built_in::configs().get(role_name)) } -fn preservation_policy(config: &Config, role_layer_toml: &TomlValue) -> (bool, bool) { - let role_selects_provider = role_layer_toml.get("model_provider").is_some(); - let role_selects_profile = role_layer_toml.get("profile").is_some(); - let role_updates_active_profile_provider = config - .active_profile - .as_ref() - .and_then(|active_profile| { - role_layer_toml - .get("profiles") - .and_then(TomlValue::as_table) - .and_then(|profiles| profiles.get(active_profile)) - .and_then(TomlValue::as_table) - .map(|profile| profile.contains_key("model_provider")) - }) - .unwrap_or(false); - let preserve_current_profile = !role_selects_provider && !role_selects_profile; - let preserve_current_provider = - preserve_current_profile && !role_updates_active_profile_provider; - (preserve_current_profile, preserve_current_provider) -} - mod reload { use super::*; pub(super) async fn build_next_config( config: &Config, role_layer_toml: TomlValue, - preserve_current_profile: bool, preserve_current_provider: bool, preserve_current_service_tier: bool, ) -> anyhow::Result { - let active_profile_name = preserve_current_profile - .then_some(config.active_profile.as_deref()) - .flatten(); - let config_layer_stack = - build_config_layer_stack(config, &role_layer_toml, active_profile_name)?; - let mut merged_config = deserialize_effective_config(config, &config_layer_stack)?; - if preserve_current_profile { - merged_config.profile = None; - } + let config_layer_stack = build_config_layer_stack(config, &role_layer_toml)?; + let merged_config = deserialize_effective_config(config, &config_layer_stack)?; - let mut next_config = Config::load_config_with_layer_stack( + let next_config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), merged_config, reload_overrides( @@ -183,23 +150,14 @@ mod reload { config_layer_stack, ) .await?; - if preserve_current_profile { - next_config.active_profile = config.active_profile.clone(); - } Ok(next_config) } fn build_config_layer_stack( config: &Config, role_layer_toml: &TomlValue, - active_profile_name: Option<&str>, ) -> anyhow::Result { let mut layers = existing_layers(config); - if let Some(resolved_profile_layer) = - resolved_profile_layer(config, &layers, role_layer_toml, active_profile_name)? - { - insert_layer(&mut layers, resolved_profile_layer); - } insert_layer(&mut layers, role_layer(role_layer_toml.clone())); Ok(ConfigLayerStack::new( layers, @@ -208,34 +166,6 @@ mod reload { )?) } - fn resolved_profile_layer( - config: &Config, - existing_layers: &[ConfigLayerEntry], - role_layer_toml: &TomlValue, - active_profile_name: Option<&str>, - ) -> anyhow::Result> { - let Some(active_profile_name) = active_profile_name else { - return Ok(None); - }; - - let mut layers = existing_layers.to_vec(); - insert_layer(&mut layers, role_layer(role_layer_toml.clone())); - let merged_config = deserialize_effective_config( - config, - &ConfigLayerStack::new( - layers, - config.config_layer_stack.requirements().clone(), - config.config_layer_stack.requirements_toml().clone(), - )?, - )?; - let resolved_profile = - merged_config.get_config_profile(Some(active_profile_name.to_string()))?; - Ok(Some(ConfigLayerEntry::new( - ConfigLayerSource::SessionFlags, - TomlValue::try_from(resolved_profile)?, - ))) - } - fn deserialize_effective_config( config: &Config, config_layer_stack: &ConfigLayerStack, diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index 828fa5e5913a..5461323a366b 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -1,13 +1,10 @@ use super::*; use crate::SkillsManager; -use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::skills_load_input_from_config; use codex_config::ConfigLayerStackOrdering; use codex_core_plugins::PluginsManager; -use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; -use codex_protocol::config_types::Verbosity; use codex_protocol::openai_models::ReasoningEffort; use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; @@ -275,301 +272,6 @@ async fn apply_role_preserves_existing_service_tier_without_override() { ); } -#[tokio::test] -async fn apply_role_preserves_active_profile_and_model_provider() { - let home = TempDir::new().expect("create temp dir"); - tokio::fs::write( - home.path().join(CONFIG_TOML_FILE), - r#" -[model_providers.test-provider] -name = "Test Provider" -base_url = "https://example.com/v1" -env_key = "TEST_PROVIDER_API_KEY" -wire_api = "responses" - -[profiles.test-profile] -model_provider = "test-provider" -"#, - ) - .await - .expect("write config.toml"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - config_profile: Some("test-profile".to_string()), - ..Default::default() - }) - .fallback_cwd(Some(home.path().to_path_buf())) - .build() - .await - .expect("load config"); - let role_path = write_role_config( - &home, - "empty-role.toml", - "developer_instructions = \"Stay focused\"", - ) - .await; - config.agent_roles.insert( - "custom".to_string(), - AgentRoleConfig { - description: None, - config_file: Some(role_path), - nickname_candidates: None, - }, - ); - - apply_role_to_config(&mut config, Some("custom")) - .await - .expect("custom role should apply"); - - assert_eq!(config.active_profile.as_deref(), Some("test-profile")); - assert_eq!(config.model_provider_id, "test-provider"); - assert_eq!(config.model_provider.name, "Test Provider"); -} - -#[tokio::test] -async fn apply_role_top_level_profile_settings_override_preserved_profile() { - let home = TempDir::new().expect("create temp dir"); - tokio::fs::write( - home.path().join(CONFIG_TOML_FILE), - r#" -[profiles.base-profile] -model = "profile-model" -model_reasoning_effort = "low" -model_reasoning_summary = "concise" -model_verbosity = "low" -"#, - ) - .await - .expect("write config.toml"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - config_profile: Some("base-profile".to_string()), - ..Default::default() - }) - .fallback_cwd(Some(home.path().to_path_buf())) - .build() - .await - .expect("load config"); - let role_path = write_role_config( - &home, - "top-level-profile-settings-role.toml", - r#"developer_instructions = "Stay focused" -model = "role-model" -model_reasoning_effort = "high" -model_reasoning_summary = "detailed" -model_verbosity = "high" -"#, - ) - .await; - config.agent_roles.insert( - "custom".to_string(), - AgentRoleConfig { - description: None, - config_file: Some(role_path), - nickname_candidates: None, - }, - ); - - apply_role_to_config(&mut config, Some("custom")) - .await - .expect("custom role should apply"); - - assert_eq!(config.active_profile.as_deref(), Some("base-profile")); - assert_eq!(config.model.as_deref(), Some("role-model")); - assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); - assert_eq!( - config.model_reasoning_summary, - Some(ReasoningSummary::Detailed) - ); - assert_eq!(config.model_verbosity, Some(Verbosity::High)); -} - -#[tokio::test] -async fn apply_role_uses_role_profile_instead_of_current_profile() { - let home = TempDir::new().expect("create temp dir"); - tokio::fs::write( - home.path().join(CONFIG_TOML_FILE), - r#" -[model_providers.base-provider] -name = "Base Provider" -base_url = "https://base.example.com/v1" -env_key = "BASE_PROVIDER_API_KEY" -wire_api = "responses" - -[model_providers.role-provider] -name = "Role Provider" -base_url = "https://role.example.com/v1" -env_key = "ROLE_PROVIDER_API_KEY" -wire_api = "responses" - -[profiles.base-profile] -model_provider = "base-provider" - -[profiles.role-profile] -model_provider = "role-provider" -"#, - ) - .await - .expect("write config.toml"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - config_profile: Some("base-profile".to_string()), - ..Default::default() - }) - .fallback_cwd(Some(home.path().to_path_buf())) - .build() - .await - .expect("load config"); - let role_path = write_role_config( - &home, - "profile-role.toml", - "developer_instructions = \"Stay focused\"\nprofile = \"role-profile\"", - ) - .await; - config.agent_roles.insert( - "custom".to_string(), - AgentRoleConfig { - description: None, - config_file: Some(role_path), - nickname_candidates: None, - }, - ); - - apply_role_to_config(&mut config, Some("custom")) - .await - .expect("custom role should apply"); - - assert_eq!(config.active_profile.as_deref(), Some("role-profile")); - assert_eq!(config.model_provider_id, "role-provider"); - assert_eq!(config.model_provider.name, "Role Provider"); -} - -#[tokio::test] -async fn apply_role_uses_role_model_provider_instead_of_current_profile_provider() { - let home = TempDir::new().expect("create temp dir"); - tokio::fs::write( - home.path().join(CONFIG_TOML_FILE), - r#" -[model_providers.base-provider] -name = "Base Provider" -base_url = "https://base.example.com/v1" -env_key = "BASE_PROVIDER_API_KEY" -wire_api = "responses" - -[model_providers.role-provider] -name = "Role Provider" -base_url = "https://role.example.com/v1" -env_key = "ROLE_PROVIDER_API_KEY" -wire_api = "responses" - -[profiles.base-profile] -model_provider = "base-provider" -"#, - ) - .await - .expect("write config.toml"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - config_profile: Some("base-profile".to_string()), - ..Default::default() - }) - .fallback_cwd(Some(home.path().to_path_buf())) - .build() - .await - .expect("load config"); - let role_path = write_role_config( - &home, - "provider-role.toml", - "developer_instructions = \"Stay focused\"\nmodel_provider = \"role-provider\"", - ) - .await; - config.agent_roles.insert( - "custom".to_string(), - AgentRoleConfig { - description: None, - config_file: Some(role_path), - nickname_candidates: None, - }, - ); - - apply_role_to_config(&mut config, Some("custom")) - .await - .expect("custom role should apply"); - - assert_eq!(config.active_profile, None); - assert_eq!(config.model_provider_id, "role-provider"); - assert_eq!(config.model_provider.name, "Role Provider"); -} - -#[tokio::test] -async fn apply_role_uses_active_profile_model_provider_update() { - let home = TempDir::new().expect("create temp dir"); - tokio::fs::write( - home.path().join(CONFIG_TOML_FILE), - r#" -[model_providers.base-provider] -name = "Base Provider" -base_url = "https://base.example.com/v1" -env_key = "BASE_PROVIDER_API_KEY" -wire_api = "responses" - -[model_providers.role-provider] -name = "Role Provider" -base_url = "https://role.example.com/v1" -env_key = "ROLE_PROVIDER_API_KEY" -wire_api = "responses" - -[profiles.base-profile] -model_provider = "base-provider" -model_reasoning_effort = "low" -"#, - ) - .await - .expect("write config.toml"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - config_profile: Some("base-profile".to_string()), - ..Default::default() - }) - .fallback_cwd(Some(home.path().to_path_buf())) - .build() - .await - .expect("load config"); - let role_path = write_role_config( - &home, - "profile-edit-role.toml", - r#"developer_instructions = "Stay focused" - -[profiles.base-profile] -model_provider = "role-provider" -model_reasoning_effort = "high" -"#, - ) - .await; - config.agent_roles.insert( - "custom".to_string(), - AgentRoleConfig { - description: None, - config_file: Some(role_path), - nickname_candidates: None, - }, - ); - - apply_role_to_config(&mut config, Some("custom")) - .await - .expect("custom role should apply"); - - assert_eq!(config.active_profile.as_deref(), Some("base-profile")); - assert_eq!(config.model_provider_id, "role-provider"); - assert_eq!(config.model_provider.name, "Role Provider"); - assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); -} - #[tokio::test] #[cfg(not(windows))] async fn apply_role_does_not_materialize_default_sandbox_workspace_write_fields() { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 07a1c2adf5e9..d84b32a8b4b1 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -938,7 +938,7 @@ impl Drop for ModelClientSession { } impl ModelClientSession { - pub(crate) fn reset_websocket_session(&mut self) { + fn reset_websocket_session(&mut self) { self.websocket_session.connection = None; self.websocket_session.last_request = None; self.websocket_session.last_response_rx = None; diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index b5802268d349..f2bded8d0652 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -100,6 +100,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 30d1e5f0e841..969da4754479 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -62,6 +62,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 101317a0ee74..0e235a941f57 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -19,6 +19,7 @@ use crate::hook_runtime::run_pre_compact_hooks; use crate::session::session::Session; use crate::session::turn::built_tools; use crate::session::turn_context::TurnContext; +use crate::util::backoff; use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; @@ -34,18 +35,22 @@ use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TruncationPolicy; use codex_protocol::protocol::TurnStartedEvent; +use codex_protocol::protocol::WarningEvent; use codex_rollout_trace::CompactionCheckpointTracePayload; use codex_rollout_trace::InferenceTraceContext; use codex_utils_output_truncation::approx_token_count; use codex_utils_output_truncation::truncate_text; use futures::StreamExt; -use futures::TryFutureExt; use tokio_util::sync::CancellationToken; use tracing::info; +use tracing::warn; // Mirror the current /responses/compact retained-message default while the // server-side path remains the reference implementation. const RETAINED_MESSAGE_TOKEN_BUDGET: usize = 64_000; +// Compact attempts can run much longer than normal turns, so keep the per-transport +// retry budget smaller than the general Responses stream retry budget. +const MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES: u64 = 2; pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, @@ -73,6 +78,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, @@ -105,7 +111,7 @@ async fn run_remote_compact_task_inner( turn_context.as_ref(), trigger, reason, - CompactionImplementation::Responses, + CompactionImplementation::ResponsesCompactionV2, phase, ) .await; @@ -277,31 +283,106 @@ async fn run_remote_compaction_request_v2( prompt: &Prompt, turn_metadata_header: Option<&str>, ) -> CodexResult<(ResponseItem, String)> { - let stream = client_session - .stream( - prompt, - &turn_context.model_info, - &turn_context.session_telemetry, - turn_context.reasoning_effort, - turn_context.reasoning_summary, - turn_context.config.service_tier.clone(), - turn_metadata_header, - &InferenceTraceContext::disabled(), - ) - .or_else(|err| async { - let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; - let compact_request_log_data = - build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text); - log_remote_compact_failure( - turn_context, - &compact_request_log_data, - total_usage_breakdown, - &err, - ); + let max_retries = turn_context + .provider + .info() + .stream_max_retries() + .min(MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES); + let mut retries = 0; + loop { + let result = match client_session + .stream( + prompt, + &turn_context.model_info, + &turn_context.session_telemetry, + turn_context.reasoning_effort, + turn_context.reasoning_summary, + turn_context.config.service_tier.clone(), + turn_metadata_header, + &InferenceTraceContext::disabled(), + ) + .await + { + Ok(stream) => collect_compaction_output(stream).await, + Err(err) => Err(err), + }; + + match result { + Ok(compaction_output) => return Ok(compaction_output), + Err(err) if !err.is_retryable() => { + log_remote_compaction_request_failure(sess, turn_context, prompt, &err).await; + return Err(err); + } Err(err) - }) - .await?; - collect_compaction_output(stream).await + if retries >= max_retries + && client_session.try_switch_fallback_transport( + &turn_context.session_telemetry, + &turn_context.model_info, + ) => + { + sess.send_event( + turn_context, + EventMsg::Warning(WarningEvent { + message: format!( + "Falling back from WebSockets to HTTPS transport. {err:#}" + ), + }), + ) + .await; + retries = 0; + } + Err(err) if retries < max_retries => { + retries += 1; + let delay = match &err { + CodexErr::Stream(_, requested_delay) => { + requested_delay.unwrap_or_else(|| backoff(retries)) + } + _ => backoff(retries), + }; + warn!( + turn_id = %turn_context.sub_id, + retries, + max_retries, + compact_error = %err, + "remote compaction v2 stream failed; retrying request after delay" + ); + + let report_error = retries > 1 + || cfg!(debug_assertions) + || !sess.services.model_client.responses_websocket_enabled(); + if report_error { + sess.notify_stream_error( + turn_context, + format!("Reconnecting... {retries}/{max_retries}"), + err, + ) + .await; + } + tokio::time::sleep(delay).await; + } + Err(err) => { + log_remote_compaction_request_failure(sess, turn_context, prompt, &err).await; + return Err(err); + } + } + } +} + +async fn log_remote_compaction_request_failure( + sess: &Session, + turn_context: &TurnContext, + prompt: &Prompt, + err: &CodexErr, +) { + let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; + let compact_request_log_data = + build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text); + log_remote_compact_failure( + turn_context, + &compact_request_log_data, + total_usage_breakdown, + err, + ); } async fn collect_compaction_output( @@ -331,8 +412,9 @@ async fn collect_compaction_output( } let Some(response_id) = completed_response_id else { - return Err(CodexErr::Fatal( + return Err(CodexErr::Stream( "remote compaction v2 stream closed before response.completed".to_string(), + None, )); }; diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index db671e1d3a94..2682e6ef41fb 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -1101,6 +1101,7 @@ allowed_approval_policies = ["on-request"] remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, @@ -1161,6 +1162,7 @@ allowed_approval_policies = ["on-request"] remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, @@ -1370,6 +1372,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 9f40aec674ce..09f17e201d33 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1,6 +1,5 @@ use crate::agents_md::DEFAULT_AGENTS_MD_FILENAME; use crate::agents_md::LOCAL_AGENTS_MD_FILENAME; -use crate::config::ThreadStoreConfig; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::config::edit::apply_blocking; @@ -14,7 +13,6 @@ use codex_config::config_toml::AgentsToml; use codex_config::config_toml::AutoReviewToml; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; -use codex_config::config_toml::RealtimeAudioConfig; use codex_config::config_toml::RealtimeConfig; use codex_config::config_toml::RealtimeToml; use codex_config::config_toml::RealtimeTransport; @@ -50,7 +48,6 @@ use codex_config::types::Notice; use codex_config::types::NotificationCondition; use codex_config::types::NotificationMethod; use codex_config::types::Notifications; -use codex_config::types::OtelConfig; use codex_config::types::OtelConfigToml; use codex_config::types::OtelExporterKind; use codex_config::types::SandboxWorkspaceWrite; @@ -111,18 +108,6 @@ use std::path::Path; use std::time::Duration; use tempfile::TempDir; -fn active_permission_profile_state( - permission_profile: PermissionProfile, - profile_id: impl Into, -) -> PermissionProfileState { - PermissionProfileState::from_constrained_active_profile( - Constrained::allow_any(permission_profile), - Some(ActivePermissionProfile::new(profile_id)), - Vec::new(), - ) - .expect("active permission profile state should be valid") -} - fn stdio_mcp(command: &str) -> McpServerConfig { McpServerConfig { transport: McpServerTransportConfig::Stdio { @@ -1408,47 +1393,6 @@ async fn network_proxy_feature_uses_profile_network_proxy_settings() -> std::io: Ok(()) } -#[tokio::test] -async fn profile_network_proxy_disable_ignores_base_feature_config() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - let cwd = TempDir::new()?; - let config = Config::load_from_base_config_with_overrides( - ConfigToml { - features: Some( - toml::from_str( - r#" -[network_proxy] -enabled = true -proxy_url = "http://127.0.0.1:43128" -"#, - ) - .expect("valid base features"), - ), - profiles: HashMap::from([( - "no_proxy".to_string(), - ConfigProfile { - features: Some( - toml::from_str("network_proxy = false").expect("valid profile features"), - ), - ..Default::default() - }, - )]), - profile: Some("no_proxy".to_string()), - ..Default::default() - }, - ConfigOverrides { - cwd: Some(cwd.path().to_path_buf()), - ..Default::default() - }, - codex_home.abs(), - ) - .await?; - - assert!(!config.features.enabled(Feature::NetworkProxy)); - assert!(config.permissions.network.is_none()); - Ok(()) -} - #[tokio::test] async fn disabled_network_proxy_feature_does_not_start_profile_proxy_policy() -> std::io::Result<()> { @@ -3459,31 +3403,6 @@ async fn runtime_config_resolves_session_picker_view_default_and_override() { cfg.tui_session_picker_view, SessionPickerViewMode::Comfortable ); - - let cfg_toml = toml::from_str::( - r#"profile = "work" - -[tui] -session_picker_view = "dense" - -[profiles.work.tui] -session_picker_view = "comfortable" -"#, - ) - .expect("parse profile scoped tui config"); - - let cfg = Config::load_from_base_config_with_overrides( - cfg_toml, - ConfigOverrides::default(), - tempdir().expect("tempdir").abs(), - ) - .await - .expect("load profile override config"); - - assert_eq!( - cfg.tui_session_picker_view, - SessionPickerViewMode::Comfortable - ); } #[tokio::test] @@ -4763,16 +4682,14 @@ async fn feedback_enabled_defaults_to_true() -> std::io::Result<()> { #[test] fn web_search_mode_defaults_to_none_if_unset() { let cfg = ConfigToml::default(); - let profile = ConfigProfile::default(); let features = Features::with_defaults(); - assert_eq!(resolve_web_search_mode(&cfg, &profile, &features), None); + assert_eq!(resolve_web_search_mode(&cfg, &features), None); } #[test] -fn web_search_mode_prefers_profile_over_legacy_flags() { - let cfg = ConfigToml::default(); - let profile = ConfigProfile { +fn web_search_mode_prefers_config_over_legacy_flags() { + let cfg = ConfigToml { web_search: Some(WebSearchMode::Live), ..Default::default() }; @@ -4780,7 +4697,7 @@ fn web_search_mode_prefers_profile_over_legacy_flags() { features.enable(Feature::WebSearchCached); assert_eq!( - resolve_web_search_mode(&cfg, &profile, &features), + resolve_web_search_mode(&cfg, &features), Some(WebSearchMode::Live) ); } @@ -4791,12 +4708,11 @@ fn web_search_mode_disabled_overrides_legacy_request() { web_search: Some(WebSearchMode::Disabled), ..Default::default() }; - let profile = ConfigProfile::default(); let mut features = Features::with_defaults(); features.enable(Feature::WebSearchRequest); assert_eq!( - resolve_web_search_mode(&cfg, &profile, &features), + resolve_web_search_mode(&cfg, &features), Some(WebSearchMode::Disabled) ); } @@ -4856,14 +4772,6 @@ async fn project_profiles_are_ignored() -> std::io::Result<()> { codex_home.path().join(CONFIG_TOML_FILE), format!( r#" -profile = "global" - -[profiles.global] -model = "gpt-global" - -[profiles.project] -model = "gpt-project" - [projects."{workspace_key}"] trust_level = "trusted" "#, @@ -4890,8 +4798,7 @@ model = "gpt-project-local" .build() .await?; - assert_eq!(config.active_profile.as_deref(), Some("global")); - assert_eq!(config.model.as_deref(), Some("gpt-global")); + assert_eq!(config.model, None); assert!( config.startup_warnings.iter().any(|warning| { warning.contains("profile") @@ -4908,7 +4815,7 @@ model = "gpt-project-local" } #[tokio::test] -async fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { +async fn unselected_profile_sandbox_mode_is_ignored() -> std::io::Result<()> { let codex_home = TempDir::new()?; let mut profiles = HashMap::new(); profiles.insert( @@ -4920,7 +4827,6 @@ async fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { ); let cfg = ConfigToml { profiles, - profile: Some("work".to_string()), sandbox_mode: Some(SandboxMode::ReadOnly), ..Default::default() }; @@ -4932,50 +4838,10 @@ async fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { ) .await?; - assert!(matches!( - &config.legacy_sandbox_policy(), - &SandboxPolicy::DangerFullAccess - )); - - Ok(()) -} - -#[tokio::test] -async fn cli_override_takes_precedence_over_profile_sandbox_mode() -> 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() - }, + assert_eq!( + config.legacy_sandbox_policy(), + SandboxPolicy::new_read_only_policy() ); - let cfg = ConfigToml { - profiles, - profile: Some("work".to_string()), - ..Default::default() - }; - - let overrides = ConfigOverrides { - sandbox_mode: Some(SandboxMode::WorkspaceWrite), - ..Default::default() - }; - - let config = - Config::load_from_base_config_with_overrides(cfg, overrides, codex_home.abs()).await?; - - if cfg!(target_os = "windows") { - assert!(matches!( - &config.legacy_sandbox_policy(), - SandboxPolicy::ReadOnly { .. } - )); - } else { - assert!(matches!( - &config.legacy_sandbox_policy(), - SandboxPolicy::WorkspaceWrite { .. } - )); - } Ok(()) } @@ -5165,7 +5031,6 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5196,7 +5061,6 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { let empty = BTreeMap::new(); apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(empty.clone())], )?; let loaded = load_global_mcp_servers(codex_home.path()).await?; @@ -5512,7 +5376,6 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5589,7 +5452,6 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5651,7 +5513,6 @@ async fn replace_mcp_servers_serializes_sourced_env_vars() -> anyhow::Result<()> apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5703,7 +5564,6 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5758,7 +5618,6 @@ async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5828,7 +5687,6 @@ async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyh )]); apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5912,7 +5770,6 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let serialized_with_optional = std::fs::read_to_string(&config_path)?; @@ -5947,7 +5804,6 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh ); apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -6046,7 +5902,6 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -6134,7 +5989,6 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -6185,7 +6039,6 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -6236,7 +6089,6 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -6293,7 +6145,6 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -6416,210 +6267,13 @@ model = "gpt-4.1" Ok(()) } -#[tokio::test] -async fn set_model_updates_profile() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_model(Some("gpt-5.4"), Some(ReasoningEffort::Medium)) - .apply() - .await?; - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - let parsed: ConfigToml = toml::from_str(&serialized)?; - let profile = parsed - .profiles - .get("dev") - .expect("profile should be created"); - - assert_eq!(profile.model.as_deref(), Some("gpt-5.4")); - assert_eq!( - profile.model_reasoning_effort, - Some(ReasoningEffort::Medium) - ); - - Ok(()) -} - -#[tokio::test] -async fn set_model_updates_existing_profile() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - let config_path = codex_home.path().join(CONFIG_TOML_FILE); - - tokio::fs::write( - &config_path, - r#" -[profiles.dev] -model = "gpt-4" -model_reasoning_effort = "medium" - -[profiles.prod] -model = "gpt-5.4" -"#, - ) - .await?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_model(Some("o4-high"), Some(ReasoningEffort::Medium)) - .apply() - .await?; - - let serialized = tokio::fs::read_to_string(config_path).await?; - let parsed: ConfigToml = toml::from_str(&serialized)?; - - let dev_profile = parsed - .profiles - .get("dev") - .expect("dev profile should survive updates"); - assert_eq!(dev_profile.model.as_deref(), Some("o4-high")); - assert_eq!( - dev_profile.model_reasoning_effort, - Some(ReasoningEffort::Medium) - ); - - assert_eq!( - parsed - .profiles - .get("prod") - .and_then(|profile| profile.model.as_deref()), - Some("gpt-5.4"), - ); - - Ok(()) -} - -#[tokio::test] -async fn set_feature_enabled_updates_profile() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_feature_enabled("guardian_approval", /*enabled*/ true) - .apply() - .await?; - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - let parsed: ConfigToml = toml::from_str(&serialized)?; - let profile = parsed - .profiles - .get("dev") - .expect("profile should be created"); - - assert_eq!( - profile - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - Some(true), - ); - assert_eq!( - parsed - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - None, - ); - - Ok(()) -} - -#[tokio::test] -async fn set_feature_enabled_persists_feature_disable_in_profile() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_feature_enabled("guardian_approval", /*enabled*/ true) - .apply() - .await?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_feature_enabled("guardian_approval", /*enabled*/ false) - .apply() - .await?; - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - let parsed: ConfigToml = toml::from_str(&serialized)?; - let profile = parsed - .profiles - .get("dev") - .expect("profile should be created"); - - assert_eq!( - profile - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - Some(false), - ); - assert_eq!( - parsed - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - None, - ); - - Ok(()) -} - -#[tokio::test] -async fn set_feature_enabled_profile_disable_overrides_root_enable() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - - ConfigEditsBuilder::new(codex_home.path()) - .set_feature_enabled("guardian_approval", /*enabled*/ true) - .apply() - .await?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_feature_enabled("guardian_approval", /*enabled*/ false) - .apply() - .await?; - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - let parsed: ConfigToml = toml::from_str(&serialized)?; - let profile = parsed - .profiles - .get("dev") - .expect("profile should be created"); - - assert_eq!( - parsed - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - Some(true), - ); - assert_eq!( - profile - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - Some(false), - ); - - Ok(()) -} - struct PrecedenceTestFixture { cwd: TempDir, codex_home: TempDir, cfg: ConfigToml, - model_provider_map: HashMap, - openai_provider: ModelProviderInfo, - openai_custom_provider: ModelProviderInfo, } impl PrecedenceTestFixture { - fn cwd(&self) -> AbsolutePathBuf { - self.cwd.abs() - } - fn cwd_path(&self) -> PathBuf { self.cwd.path().to_path_buf() } @@ -8019,10 +7673,6 @@ fn create_test_fixture() -> std::io::Result { model = "o3" approval_policy = "untrusted" -# Can be used to determine which profile to use if not specified by -# `ConfigOverrides`. -profile = "gpt3" - [analytics] enabled = true @@ -8076,207 +7726,34 @@ model_verbosity = "high" let codex_home_temp_dir = TempDir::new().unwrap(); - let openai_custom_provider = ModelProviderInfo { - name: "OpenAI custom".to_string(), - base_url: Some("https://api.openai.com/v1".to_string()), - env_key: Some("OPENAI_API_KEY".to_string()), - wire_api: WireApi::Responses, - env_key_instructions: None, - experimental_bearer_token: None, - auth: None, - aws: None, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(4), - stream_max_retries: Some(10), - stream_idle_timeout_ms: Some(300_000), - websocket_connect_timeout_ms: Some(15_000), - requires_openai_auth: false, - supports_websockets: false, - }; - let model_provider_map = { - let mut model_provider_map = - built_in_model_providers(/* openai_base_url */ /*openai_base_url*/ None); - model_provider_map.insert("openai-custom".to_string(), openai_custom_provider.clone()); - model_provider_map - }; - - let openai_provider = model_provider_map - .get("openai") - .expect("openai provider should exist") - .clone(); - Ok(PrecedenceTestFixture { cwd: cwd_temp_dir, codex_home: codex_home_temp_dir, cfg, - model_provider_map, - openai_provider, - openai_custom_provider, }) } -/// Users can specify config values at multiple levels that have the -/// following precedence: -/// -/// 1. custom command-line argument, e.g. `--model o3` -/// 2. as part of a profile, where the `--profile` is specified via a CLI -/// (or in the config file itself) -/// 3. as an entry in `config.toml`, e.g. `model = "o3"` -/// 4. the default value for a required field defined in code. -/// -/// Note that profiles are the recommended way to specify a group of -/// configuration options together. #[tokio::test] -async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { - let fixture = create_test_fixture()?; +async fn legacy_profile_selection_is_rejected() -> std::io::Result<()> { + let mut fixture = create_test_fixture()?; + fixture.cfg.profile = Some("gpt3".to_string()); - let o3_profile_overrides = ConfigOverrides { - config_profile: Some("o3".to_string()), - cwd: Some(fixture.cwd_path()), - ..Default::default() - }; - let o3_profile_config: Config = Config::load_from_base_config_with_overrides( + let err = Config::load_from_base_config_with_overrides( fixture.cfg.clone(), - o3_profile_overrides, + ConfigOverrides { + cwd: Some(fixture.cwd_path()), + ..Default::default() + }, fixture.codex_home(), ) - .await?; - assert_eq!( - Config { - model: Some("o3".to_string()), - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total, - service_tier: None, - model_provider_id: "openai".to_string(), - model_provider: fixture.openai_provider.clone(), - permissions: Permissions { - approval_policy: Constrained::allow_any(AskForApproval::Never), - permission_profile_state: active_permission_profile_state( - PermissionProfile::read_only(), - BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - ), - workspace_roots: vec![fixture.cwd()], - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - windows_sandbox_private_desktop: true, - }, - explicit_permission_profile_mode: false, - custom_permission_profile_ids: Vec::new(), - approvals_reviewer: ApprovalsReviewer::User, - enforce_residency: Constrained::allow_any(/*initial_value*/ None), - user_instructions: None, - notify: None, - cwd: fixture.cwd(), - workspace_roots: vec![fixture.cwd()], - workspace_roots_explicit: false, - cli_auth_credentials_store_mode: Default::default(), - mcp_servers: Constrained::allow_any(HashMap::new()), - mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( - Default::default(), - LOCAL_DEV_BUILD_VERSION, - ), - mcp_oauth_callback_port: None, - mcp_oauth_callback_url: None, - model_providers: fixture.model_provider_map.clone(), - project_doc_max_bytes: AGENTS_MD_MAX_BYTES, - project_doc_fallback_filenames: Vec::new(), - tool_output_token_limit: None, - agent_max_threads: DEFAULT_AGENT_MAX_THREADS, - agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, - agent_roles: BTreeMap::new(), - memories: MemoriesConfig::default(), - agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, - agent_interrupt_message_enabled: true, - codex_home: fixture.codex_home(), - sqlite_home: fixture.codex_home().to_path_buf(), - log_dir: fixture.codex_home().join("log").to_path_buf(), - config_lock_export_dir: None, - config_lock_allow_codex_version_mismatch: false, - config_lock_save_fields_resolved_from_model_catalog: true, - config_lock_toml: None, - config_layer_stack: Default::default(), - startup_warnings: Vec::new(), - history: History::default(), - ephemeral: false, - bypass_hook_trust: false, - file_opener: UriBasedFileOpener::VsCode, - codex_self_exe: None, - codex_linux_sandbox_exe: None, - main_execve_wrapper_exe: None, - zsh_path: None, - hide_agent_reasoning: false, - show_raw_agent_reasoning: false, - model_reasoning_effort: Some(ReasoningEffort::High), - plan_mode_reasoning_effort: None, - model_reasoning_summary: Some(ReasoningSummary::Detailed), - model_supports_reasoning_summaries: None, - model_catalog: None, - model_verbosity: None, - personality: Some(Personality::Pragmatic), - chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - apps_mcp_path_override: None, - apps_mcp_product_sku: None, - realtime_audio: RealtimeAudioConfig::default(), - experimental_realtime_start_instructions: None, - experimental_realtime_ws_base_url: None, - experimental_realtime_ws_model: None, - realtime: RealtimeConfig::default(), - experimental_realtime_ws_backend_prompt: None, - experimental_realtime_ws_startup_context: None, - experimental_thread_config_endpoint: None, - experimental_thread_store: ThreadStoreConfig::Local, - base_instructions: None, - developer_instructions: None, - guardian_policy_config: None, - include_permissions_instructions: true, - include_apps_instructions: true, - include_collaboration_mode_instructions: true, - include_skill_instructions: true, - include_environment_context: true, - compact_prompt: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search_mode: Constrained::allow_any(WebSearchMode::Cached), - web_search_config: None, - use_experimental_unified_exec_tool: !cfg!(windows), - background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, - ghost_snapshot: GhostSnapshotConfig::default(), - multi_agent_v2: MultiAgentV2Config::default(), - features: Features::with_defaults().into(), - suppress_unstable_features_warning: false, - active_profile: Some("o3".to_string()), - active_project: ProjectConfig { trust_level: None }, - notices: Default::default(), - check_for_update_on_startup: true, - disable_paste_burst: false, - tui_notifications: Default::default(), - animations: true, - show_tooltips: true, - tui_vim_mode_default: false, - tui_raw_output_mode: false, - tui_keymap: TuiKeymap::default(), - model_availability_nux: ModelAvailabilityNuxConfig::default(), - terminal_resize_reflow: TerminalResizeReflowConfig::default(), - analytics_enabled: Some(true), - feedback_enabled: true, - tool_suggest: ToolSuggestConfig::default(), - tui_alternate_screen: AltScreenMode::Auto, - tui_status_line: None, - tui_status_line_use_colors: true, - tui_terminal_title: None, - tui_theme: None, - tui_pet: None, - tui_pet_anchor: TuiPetAnchor::Composer, - tui_session_picker_view: SessionPickerViewMode::Dense, - otel: OtelConfig::default(), - }, - o3_profile_config + .await + .expect_err("legacy profile selection should be rejected"); + + assert_eq!(err.kind(), ErrorKind::InvalidData); + assert!( + err.to_string() + .contains("legacy `profile = \"gpt3\"` config is no longer supported"), + "unexpected error: {err}" ); Ok(()) } @@ -8608,480 +8085,6 @@ async fn fast_default_opt_out_notice_config_is_respected() -> std::io::Result<() Ok(()) } -#[tokio::test] -async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { - let fixture = create_test_fixture()?; - - let gpt3_profile_overrides = ConfigOverrides { - config_profile: Some("gpt3".to_string()), - cwd: Some(fixture.cwd_path()), - ..Default::default() - }; - let gpt3_profile_config = Config::load_from_base_config_with_overrides( - fixture.cfg.clone(), - gpt3_profile_overrides, - fixture.codex_home(), - ) - .await?; - let expected_gpt3_profile_config = Config { - model: Some("gpt-3.5-turbo".to_string()), - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total, - service_tier: None, - model_provider_id: "openai-custom".to_string(), - model_provider: fixture.openai_custom_provider.clone(), - permissions: Permissions { - approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), - permission_profile_state: active_permission_profile_state( - PermissionProfile::read_only(), - BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - ), - workspace_roots: vec![fixture.cwd()], - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - windows_sandbox_private_desktop: true, - }, - explicit_permission_profile_mode: false, - custom_permission_profile_ids: Vec::new(), - approvals_reviewer: ApprovalsReviewer::User, - enforce_residency: Constrained::allow_any(/*initial_value*/ None), - user_instructions: None, - notify: None, - cwd: fixture.cwd(), - workspace_roots: vec![fixture.cwd()], - workspace_roots_explicit: false, - cli_auth_credentials_store_mode: Default::default(), - mcp_servers: Constrained::allow_any(HashMap::new()), - mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( - Default::default(), - LOCAL_DEV_BUILD_VERSION, - ), - mcp_oauth_callback_port: None, - mcp_oauth_callback_url: None, - model_providers: fixture.model_provider_map.clone(), - project_doc_max_bytes: AGENTS_MD_MAX_BYTES, - project_doc_fallback_filenames: Vec::new(), - tool_output_token_limit: None, - agent_max_threads: DEFAULT_AGENT_MAX_THREADS, - agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, - agent_roles: BTreeMap::new(), - memories: MemoriesConfig::default(), - agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, - agent_interrupt_message_enabled: true, - codex_home: fixture.codex_home(), - sqlite_home: fixture.codex_home().to_path_buf(), - log_dir: fixture.codex_home().join("log").to_path_buf(), - config_lock_export_dir: None, - config_lock_allow_codex_version_mismatch: false, - config_lock_save_fields_resolved_from_model_catalog: true, - config_lock_toml: None, - config_layer_stack: Default::default(), - startup_warnings: Vec::new(), - history: History::default(), - ephemeral: false, - bypass_hook_trust: false, - file_opener: UriBasedFileOpener::VsCode, - codex_self_exe: None, - codex_linux_sandbox_exe: None, - main_execve_wrapper_exe: None, - zsh_path: None, - hide_agent_reasoning: false, - show_raw_agent_reasoning: false, - model_reasoning_effort: None, - plan_mode_reasoning_effort: None, - model_reasoning_summary: None, - model_supports_reasoning_summaries: None, - model_catalog: None, - model_verbosity: None, - personality: Some(Personality::Pragmatic), - chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - apps_mcp_path_override: None, - apps_mcp_product_sku: None, - realtime_audio: RealtimeAudioConfig::default(), - experimental_realtime_start_instructions: None, - experimental_realtime_ws_base_url: None, - experimental_realtime_ws_model: None, - realtime: RealtimeConfig::default(), - experimental_realtime_ws_backend_prompt: None, - experimental_realtime_ws_startup_context: None, - experimental_thread_config_endpoint: None, - experimental_thread_store: ThreadStoreConfig::Local, - base_instructions: None, - developer_instructions: None, - guardian_policy_config: None, - include_permissions_instructions: true, - include_apps_instructions: true, - include_collaboration_mode_instructions: true, - include_skill_instructions: true, - include_environment_context: true, - compact_prompt: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search_mode: Constrained::allow_any(WebSearchMode::Cached), - web_search_config: None, - use_experimental_unified_exec_tool: !cfg!(windows), - background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, - ghost_snapshot: GhostSnapshotConfig::default(), - multi_agent_v2: MultiAgentV2Config::default(), - features: Features::with_defaults().into(), - suppress_unstable_features_warning: false, - active_profile: Some("gpt3".to_string()), - active_project: ProjectConfig { trust_level: None }, - notices: Default::default(), - check_for_update_on_startup: true, - disable_paste_burst: false, - tui_notifications: Default::default(), - animations: true, - show_tooltips: true, - tui_vim_mode_default: false, - tui_raw_output_mode: false, - tui_keymap: TuiKeymap::default(), - model_availability_nux: ModelAvailabilityNuxConfig::default(), - terminal_resize_reflow: TerminalResizeReflowConfig::default(), - analytics_enabled: Some(true), - feedback_enabled: true, - tool_suggest: ToolSuggestConfig::default(), - tui_alternate_screen: AltScreenMode::Auto, - tui_status_line: None, - tui_status_line_use_colors: true, - tui_terminal_title: None, - tui_theme: None, - tui_pet: None, - tui_pet_anchor: TuiPetAnchor::Composer, - tui_session_picker_view: SessionPickerViewMode::Dense, - otel: OtelConfig::default(), - }; - - assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); - - // Verify that loading without specifying a profile in ConfigOverrides - // uses the default profile from the config file (which is "gpt3"). - let default_profile_overrides = ConfigOverrides { - cwd: Some(fixture.cwd_path()), - ..Default::default() - }; - - let default_profile_config = Config::load_from_base_config_with_overrides( - fixture.cfg.clone(), - default_profile_overrides, - fixture.codex_home(), - ) - .await?; - - assert_eq!(expected_gpt3_profile_config, default_profile_config); - Ok(()) -} - -#[tokio::test] -async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { - let fixture = create_test_fixture()?; - - let zdr_profile_overrides = ConfigOverrides { - config_profile: Some("zdr".to_string()), - cwd: Some(fixture.cwd_path()), - ..Default::default() - }; - let zdr_profile_config = Config::load_from_base_config_with_overrides( - fixture.cfg.clone(), - zdr_profile_overrides, - fixture.codex_home(), - ) - .await?; - let expected_zdr_profile_config = Config { - model: Some("o3".to_string()), - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total, - service_tier: None, - model_provider_id: "openai".to_string(), - model_provider: fixture.openai_provider.clone(), - permissions: Permissions { - approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - permission_profile_state: active_permission_profile_state( - PermissionProfile::read_only(), - BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - ), - workspace_roots: vec![fixture.cwd()], - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - windows_sandbox_private_desktop: true, - }, - explicit_permission_profile_mode: false, - custom_permission_profile_ids: Vec::new(), - approvals_reviewer: ApprovalsReviewer::User, - enforce_residency: Constrained::allow_any(/*initial_value*/ None), - user_instructions: None, - notify: None, - cwd: fixture.cwd(), - workspace_roots: vec![fixture.cwd()], - workspace_roots_explicit: false, - cli_auth_credentials_store_mode: Default::default(), - mcp_servers: Constrained::allow_any(HashMap::new()), - mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( - Default::default(), - LOCAL_DEV_BUILD_VERSION, - ), - mcp_oauth_callback_port: None, - mcp_oauth_callback_url: None, - model_providers: fixture.model_provider_map.clone(), - project_doc_max_bytes: AGENTS_MD_MAX_BYTES, - project_doc_fallback_filenames: Vec::new(), - tool_output_token_limit: None, - agent_max_threads: DEFAULT_AGENT_MAX_THREADS, - agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, - agent_roles: BTreeMap::new(), - memories: MemoriesConfig::default(), - agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, - agent_interrupt_message_enabled: true, - codex_home: fixture.codex_home(), - sqlite_home: fixture.codex_home().to_path_buf(), - log_dir: fixture.codex_home().join("log").to_path_buf(), - config_lock_export_dir: None, - config_lock_allow_codex_version_mismatch: false, - config_lock_save_fields_resolved_from_model_catalog: true, - config_lock_toml: None, - config_layer_stack: Default::default(), - startup_warnings: Vec::new(), - history: History::default(), - ephemeral: false, - bypass_hook_trust: false, - file_opener: UriBasedFileOpener::VsCode, - codex_self_exe: None, - codex_linux_sandbox_exe: None, - main_execve_wrapper_exe: None, - zsh_path: None, - hide_agent_reasoning: false, - show_raw_agent_reasoning: false, - model_reasoning_effort: None, - plan_mode_reasoning_effort: None, - model_reasoning_summary: None, - model_supports_reasoning_summaries: None, - model_catalog: None, - model_verbosity: None, - personality: Some(Personality::Pragmatic), - chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - apps_mcp_path_override: None, - apps_mcp_product_sku: None, - realtime_audio: RealtimeAudioConfig::default(), - experimental_realtime_start_instructions: None, - experimental_realtime_ws_base_url: None, - experimental_realtime_ws_model: None, - realtime: RealtimeConfig::default(), - experimental_realtime_ws_backend_prompt: None, - experimental_realtime_ws_startup_context: None, - experimental_thread_config_endpoint: None, - experimental_thread_store: ThreadStoreConfig::Local, - base_instructions: None, - developer_instructions: None, - guardian_policy_config: None, - include_permissions_instructions: true, - include_apps_instructions: true, - include_collaboration_mode_instructions: true, - include_skill_instructions: true, - include_environment_context: true, - compact_prompt: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search_mode: Constrained::allow_any(WebSearchMode::Cached), - web_search_config: None, - use_experimental_unified_exec_tool: !cfg!(windows), - background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, - ghost_snapshot: GhostSnapshotConfig::default(), - multi_agent_v2: MultiAgentV2Config::default(), - features: Features::with_defaults().into(), - suppress_unstable_features_warning: false, - active_profile: Some("zdr".to_string()), - active_project: ProjectConfig { trust_level: None }, - notices: Default::default(), - check_for_update_on_startup: true, - disable_paste_burst: false, - tui_notifications: Default::default(), - animations: true, - show_tooltips: true, - tui_vim_mode_default: false, - tui_raw_output_mode: false, - tui_keymap: TuiKeymap::default(), - model_availability_nux: ModelAvailabilityNuxConfig::default(), - terminal_resize_reflow: TerminalResizeReflowConfig::default(), - analytics_enabled: Some(false), - feedback_enabled: true, - tool_suggest: ToolSuggestConfig::default(), - tui_alternate_screen: AltScreenMode::Auto, - tui_status_line: None, - tui_status_line_use_colors: true, - tui_terminal_title: None, - tui_theme: None, - tui_pet: None, - tui_pet_anchor: TuiPetAnchor::Composer, - tui_session_picker_view: SessionPickerViewMode::Dense, - otel: OtelConfig::default(), - }; - - assert_eq!(expected_zdr_profile_config, zdr_profile_config); - - Ok(()) -} - -#[tokio::test] -async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { - let fixture = create_test_fixture()?; - - let gpt5_profile_overrides = ConfigOverrides { - config_profile: Some("gpt5".to_string()), - cwd: Some(fixture.cwd_path()), - ..Default::default() - }; - let gpt5_profile_config = Config::load_from_base_config_with_overrides( - fixture.cfg.clone(), - gpt5_profile_overrides, - fixture.codex_home(), - ) - .await?; - let expected_gpt5_profile_config = Config { - model: Some("gpt-5.4".to_string()), - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope::Total, - service_tier: None, - model_provider_id: "openai".to_string(), - model_provider: fixture.openai_provider.clone(), - permissions: Permissions { - approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - permission_profile_state: active_permission_profile_state( - PermissionProfile::read_only(), - BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - ), - workspace_roots: vec![fixture.cwd()], - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - windows_sandbox_private_desktop: true, - }, - explicit_permission_profile_mode: false, - custom_permission_profile_ids: Vec::new(), - approvals_reviewer: ApprovalsReviewer::User, - enforce_residency: Constrained::allow_any(/*initial_value*/ None), - user_instructions: None, - notify: None, - cwd: fixture.cwd(), - workspace_roots: vec![fixture.cwd()], - workspace_roots_explicit: false, - cli_auth_credentials_store_mode: Default::default(), - mcp_servers: Constrained::allow_any(HashMap::new()), - mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( - Default::default(), - LOCAL_DEV_BUILD_VERSION, - ), - mcp_oauth_callback_port: None, - mcp_oauth_callback_url: None, - model_providers: fixture.model_provider_map.clone(), - project_doc_max_bytes: AGENTS_MD_MAX_BYTES, - project_doc_fallback_filenames: Vec::new(), - tool_output_token_limit: None, - agent_max_threads: DEFAULT_AGENT_MAX_THREADS, - agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, - agent_roles: BTreeMap::new(), - memories: MemoriesConfig::default(), - agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, - agent_interrupt_message_enabled: true, - codex_home: fixture.codex_home(), - sqlite_home: fixture.codex_home().to_path_buf(), - log_dir: fixture.codex_home().join("log").to_path_buf(), - config_lock_export_dir: None, - config_lock_allow_codex_version_mismatch: false, - config_lock_save_fields_resolved_from_model_catalog: true, - config_lock_toml: None, - config_layer_stack: Default::default(), - startup_warnings: Vec::new(), - history: History::default(), - ephemeral: false, - bypass_hook_trust: false, - file_opener: UriBasedFileOpener::VsCode, - codex_self_exe: None, - codex_linux_sandbox_exe: None, - main_execve_wrapper_exe: None, - zsh_path: None, - hide_agent_reasoning: false, - show_raw_agent_reasoning: false, - model_reasoning_effort: Some(ReasoningEffort::High), - plan_mode_reasoning_effort: None, - model_reasoning_summary: Some(ReasoningSummary::Detailed), - model_supports_reasoning_summaries: None, - model_catalog: None, - model_verbosity: Some(Verbosity::High), - personality: Some(Personality::Pragmatic), - chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - apps_mcp_path_override: None, - apps_mcp_product_sku: None, - realtime_audio: RealtimeAudioConfig::default(), - experimental_realtime_start_instructions: None, - experimental_realtime_ws_base_url: None, - experimental_realtime_ws_model: None, - realtime: RealtimeConfig::default(), - experimental_realtime_ws_backend_prompt: None, - experimental_realtime_ws_startup_context: None, - experimental_thread_config_endpoint: None, - experimental_thread_store: ThreadStoreConfig::Local, - base_instructions: None, - developer_instructions: None, - guardian_policy_config: None, - include_permissions_instructions: true, - include_apps_instructions: true, - include_collaboration_mode_instructions: true, - include_skill_instructions: true, - include_environment_context: true, - compact_prompt: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search_mode: Constrained::allow_any(WebSearchMode::Cached), - web_search_config: None, - use_experimental_unified_exec_tool: !cfg!(windows), - background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, - ghost_snapshot: GhostSnapshotConfig::default(), - multi_agent_v2: MultiAgentV2Config::default(), - features: Features::with_defaults().into(), - suppress_unstable_features_warning: false, - active_profile: Some("gpt5".to_string()), - active_project: ProjectConfig { trust_level: None }, - notices: Default::default(), - check_for_update_on_startup: true, - disable_paste_burst: false, - tui_notifications: Default::default(), - animations: true, - show_tooltips: true, - tui_vim_mode_default: false, - tui_raw_output_mode: false, - tui_keymap: TuiKeymap::default(), - model_availability_nux: ModelAvailabilityNuxConfig::default(), - terminal_resize_reflow: TerminalResizeReflowConfig::default(), - analytics_enabled: Some(true), - feedback_enabled: true, - tool_suggest: ToolSuggestConfig::default(), - tui_alternate_screen: AltScreenMode::Auto, - tui_status_line: None, - tui_status_line_use_colors: true, - tui_terminal_title: None, - tui_theme: None, - tui_pet: None, - tui_pet_anchor: TuiPetAnchor::Composer, - tui_session_picker_view: SessionPickerViewMode::Dense, - otel: OtelConfig::default(), - }; - - assert_eq!(expected_gpt5_profile_config, gpt5_profile_config); - - Ok(()) -} - #[tokio::test] async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> anyhow::Result<()> { @@ -9095,6 +8098,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() remote_sandbox_config: None, allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]), allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, @@ -9507,35 +8511,10 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb #[test] fn test_resolve_oss_provider_explicit_override() { let config_toml = ConfigToml::default(); - let result = resolve_oss_provider( - Some("custom-provider"), - &config_toml, - /*config_profile*/ None, - ); + let result = resolve_oss_provider(Some("custom-provider"), &config_toml); assert_eq!(result, Some("custom-provider".to_string())); } -#[test] -fn test_resolve_oss_provider_from_profile() { - let mut profiles = std::collections::HashMap::new(); - let profile = ConfigProfile { - oss_provider: Some("profile-provider".to_string()), - ..Default::default() - }; - profiles.insert("test-profile".to_string(), profile); - let config_toml = ConfigToml { - profiles, - ..Default::default() - }; - - let result = resolve_oss_provider( - /*explicit_provider*/ None, - &config_toml, - Some("test-profile".to_string()), - ); - assert_eq!(result, Some("profile-provider".to_string())); -} - #[test] fn test_resolve_oss_provider_from_global_config() { let config_toml = ConfigToml { @@ -9543,63 +8522,25 @@ fn test_resolve_oss_provider_from_global_config() { ..Default::default() }; - let result = resolve_oss_provider( - /*explicit_provider*/ None, - &config_toml, - /*config_profile*/ None, - ); - assert_eq!(result, Some("global-provider".to_string())); -} - -#[test] -fn test_resolve_oss_provider_profile_fallback_to_global() { - let mut profiles = std::collections::HashMap::new(); - let profile = ConfigProfile::default(); // No oss_provider set - profiles.insert("test-profile".to_string(), profile); - let config_toml = ConfigToml { - oss_provider: Some("global-provider".to_string()), - profiles, - ..Default::default() - }; - - let result = resolve_oss_provider( - /*explicit_provider*/ None, - &config_toml, - Some("test-profile".to_string()), - ); + let result = resolve_oss_provider(/*explicit_provider*/ None, &config_toml); assert_eq!(result, Some("global-provider".to_string())); } #[test] fn test_resolve_oss_provider_none_when_not_configured() { let config_toml = ConfigToml::default(); - let result = resolve_oss_provider( - /*explicit_provider*/ None, - &config_toml, - /*config_profile*/ None, - ); + let result = resolve_oss_provider(/*explicit_provider*/ None, &config_toml); assert_eq!(result, None); } #[test] -fn test_resolve_oss_provider_explicit_overrides_all() { - let mut profiles = std::collections::HashMap::new(); - let profile = ConfigProfile { - oss_provider: Some("profile-provider".to_string()), - ..Default::default() - }; - profiles.insert("test-profile".to_string(), profile); +fn test_resolve_oss_provider_explicit_overrides_global() { let config_toml = ConfigToml { oss_provider: Some("global-provider".to_string()), - profiles, ..Default::default() }; - let result = resolve_oss_provider( - Some("explicit-provider"), - &config_toml, - Some("test-profile".to_string()), - ); + let result = resolve_oss_provider(Some("explicit-provider"), &config_toml); assert_eq!(result, Some("explicit-provider".to_string())); } @@ -9880,6 +8821,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, feature_requirements: None, hooks: None, @@ -10430,8 +9372,7 @@ async fn approvals_reviewer_defaults_to_manual_only_without_guardian_feature() - } #[tokio::test] -async fn prompt_instruction_blocks_can_be_disabled_from_config_and_profiles() -> std::io::Result<()> -{ +async fn prompt_instruction_blocks_can_be_disabled_from_config() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -10439,15 +9380,9 @@ async fn prompt_instruction_blocks_can_be_disabled_from_config_and_profiles() -> include_apps_instructions = false include_collaboration_mode_instructions = false include_environment_context = false -profile = "chatty" [skills] include_instructions = false - -[profiles.chatty] -include_permissions_instructions = true -include_collaboration_mode_instructions = true -include_environment_context = true "#, )?; @@ -10457,11 +9392,11 @@ include_environment_context = true .build() .await?; - assert!(config.include_permissions_instructions); + assert!(!config.include_permissions_instructions); assert!(!config.include_apps_instructions); - assert!(config.include_collaboration_mode_instructions); + assert!(!config.include_collaboration_mode_instructions); assert!(!config.include_skill_instructions); - assert!(config.include_environment_context); + assert!(!config.include_environment_context); Ok(()) } @@ -10506,29 +9441,6 @@ async fn approvals_reviewer_can_be_set_in_config_without_guardian_approval() -> Ok(()) } -#[tokio::test] -async fn approvals_reviewer_can_be_set_in_profile_without_guardian_approval() -> std::io::Result<()> -{ - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"profile = "guardian" - -[profiles.guardian] -approvals_reviewer = "guardian_subagent" -"#, - )?; - - 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?; - - assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); - Ok(()) -} - #[tokio::test] async fn requirements_disallowing_default_approvals_reviewer_falls_back_to_required_default() -> std::io::Result<()> { @@ -10583,35 +9495,6 @@ 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()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"profile = "default" - -[profiles.default] -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())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), - ..Default::default() - })) - })) - .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<()> { @@ -10676,36 +9559,6 @@ smart_approvals = true Ok(()) } -#[tokio::test] -async fn smart_approvals_alias_is_ignored_in_profiles() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"profile = "guardian" - -[profiles.guardian.features] -smart_approvals = 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())) - .build() - .await?; - - assert!(config.features.enabled(Feature::GuardianApproval)); - assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("[profiles.guardian.features]")); - assert!(serialized.contains("smart_approvals = true")); - assert!(!serialized.contains("guardian_approval")); - assert!(!serialized.contains("approvals_reviewer")); - - Ok(()) -} - #[tokio::test] async fn multi_agent_v2_config_from_feature_table() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -10762,74 +9615,6 @@ non_code_mode_only = true Ok(()) } -#[tokio::test] -async fn profile_multi_agent_v2_config_overrides_base() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"profile = "no_hint" - -[features.multi_agent_v2] -max_concurrent_threads_per_session = 4 -min_wait_timeout_ms = 3000 -max_wait_timeout_ms = 120000 -default_wait_timeout_ms = 30000 -usage_hint_enabled = true -usage_hint_text = "base hint" -root_agent_usage_hint_text = "base root hint" -subagent_usage_hint_text = "base subagent hint" -tool_namespace = "base_agents" -hide_spawn_agent_metadata = true -non_code_mode_only = false - -[profiles.no_hint.features.multi_agent_v2] -max_concurrent_threads_per_session = 6 -min_wait_timeout_ms = 1500 -max_wait_timeout_ms = 90000 -default_wait_timeout_ms = 15000 -usage_hint_enabled = false -usage_hint_text = "profile hint" -root_agent_usage_hint_text = "profile root hint" -subagent_usage_hint_text = "profile subagent hint" -tool_namespace = "profile_agents" -hide_spawn_agent_metadata = false -non_code_mode_only = 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())) - .build() - .await?; - - assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 6); - assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 1500); - assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 90000); - assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 15000); - assert!(!config.multi_agent_v2.usage_hint_enabled); - assert_eq!( - config.multi_agent_v2.usage_hint_text.as_deref(), - Some("profile hint") - ); - assert_eq!( - config.multi_agent_v2.root_agent_usage_hint_text.as_deref(), - Some("profile root hint") - ); - assert_eq!( - config.multi_agent_v2.subagent_usage_hint_text.as_deref(), - Some("profile subagent hint") - ); - assert_eq!( - config.multi_agent_v2.tool_namespace.as_deref(), - Some("profile_agents") - ); - assert!(!config.multi_agent_v2.hide_spawn_agent_metadata); - assert!(config.multi_agent_v2.non_code_mode_only); - - Ok(()) -} - #[tokio::test] async fn multi_agent_v2_default_session_thread_cap_counts_root() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 1b5cd879c45c..ca54a7c3f577 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -513,13 +513,6 @@ mod document_helpers { struct ConfigDocument { doc: DocumentMut, - profile: Option, -} - -#[derive(Copy, Clone)] -enum Scope { - Global, - Profile, } #[derive(Copy, Clone)] @@ -529,25 +522,25 @@ enum TraversalMode { } impl ConfigDocument { - fn new(doc: DocumentMut, profile: Option) -> Self { - Self { doc, profile } + fn new(doc: DocumentMut) -> Self { + Self { doc } } fn apply(&mut self, edit: &ConfigEdit) -> anyhow::Result { match edit { ConfigEdit::SetModel { model, effort } => Ok({ let mut mutated = false; - mutated |= self.write_profile_value( + mutated |= self.write_optional_value( &["model"], model.as_ref().map(|model_value| value(model_value.clone())), ); - mutated |= self.write_profile_value( + mutated |= self.write_optional_value( &["model_reasoning_effort"], effort.map(|effort| value(effort.to_string())), ); mutated }), - ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_profile_value( + ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_optional_value( &["service_tier"], service_tier.as_ref().map(|service_tier| { // Keep the legacy config spelling stable. Runtime values use @@ -560,35 +553,30 @@ impl ConfigDocument { value(config_value) }), )), - ConfigEdit::SetModelPersonality { personality } => Ok(self.write_profile_value( + ConfigEdit::SetModelPersonality { personality } => Ok(self.write_optional_value( &["personality"], personality.map(|personality| value(personality.to_string())), )), ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged) => Ok(self.write_value( - Scope::Global, &[NOTICE_TABLE_KEY, "hide_full_access_warning"], value(*acknowledged), )), ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged) => Ok(self.write_value( - Scope::Global, &[NOTICE_TABLE_KEY, "hide_world_writable_warning"], value(*acknowledged), )), ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value( - Scope::Global, &[NOTICE_TABLE_KEY, "hide_rate_limit_model_nudge"], value(*acknowledged), )), ConfigEdit::SetNoticeHideModelMigrationPrompt(migration_config, acknowledged) => { Ok(self.write_value( - Scope::Global, &[NOTICE_TABLE_KEY, migration_config.as_str()], value(*acknowledged), )) } ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(acknowledged) => Ok(self .write_value( - Scope::Global, &[ NOTICE_TABLE_KEY, "external_config_migration_prompts", @@ -598,7 +586,6 @@ impl ConfigDocument { )), ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(timestamp) => { Ok(self.write_value( - Scope::Global, &[ NOTICE_TABLE_KEY, "external_config_migration_prompts", @@ -611,7 +598,6 @@ impl ConfigDocument { project, acknowledged, ) => Ok(self.write_value( - Scope::Global, &[ NOTICE_TABLE_KEY, "external_config_migration_prompts", @@ -624,7 +610,6 @@ impl ConfigDocument { project, timestamp, ) => Ok(self.write_value( - Scope::Global, &[ NOTICE_TABLE_KEY, "external_config_migration_prompts", @@ -634,7 +619,6 @@ impl ConfigDocument { value(*timestamp), )), ConfigEdit::RecordModelMigrationSeen { from, to } => Ok(self.write_value( - Scope::Global, &[NOTICE_TABLE_KEY, "model_migrations", from.as_str()], value(to.clone()), )), @@ -663,20 +647,26 @@ impl ConfigDocument { } } - fn write_profile_value(&mut self, segments: &[&str], value: Option) -> bool { + fn write_optional_value(&mut self, segments: &[&str], value: Option) -> bool { match value { - Some(item) => self.write_value(Scope::Profile, segments, item), - None => self.clear(Scope::Profile, segments), + Some(item) => self.write_value(segments, item), + None => self.clear(segments), } } - fn write_value(&mut self, scope: Scope, segments: &[&str], value: TomlItem) -> bool { - let resolved = self.scoped_segments(scope, segments); + fn write_value(&mut self, segments: &[&str], value: TomlItem) -> bool { + let resolved = segments + .iter() + .map(|segment| (*segment).to_string()) + .collect::>(); self.insert(&resolved, value) } - fn clear(&mut self, scope: Scope, segments: &[&str]) -> bool { - let resolved = self.scoped_segments(scope, segments); + fn clear(&mut self, segments: &[&str]) -> bool { + let resolved = segments + .iter() + .map(|segment| (*segment).to_string()) + .collect::>(); self.remove(&resolved) } @@ -709,7 +699,6 @@ impl ConfigDocument { .filter(|disabled_tool| seen.insert(disabled_tool.clone())) .collect::>(); self.write_value( - Scope::Global, &["tool_suggest", "disabled_tools"], document_helpers::tool_suggest_disabled_tools_value(&disabled_tools), ) @@ -721,7 +710,7 @@ impl ConfigDocument { fn replace_mcp_servers(&mut self, servers: &BTreeMap) -> bool { if servers.is_empty() { - return self.clear(Scope::Global, &["mcp_servers"]); + return self.clear(&["mcp_servers"]); } let root = self.doc.as_table_mut(); @@ -883,26 +872,6 @@ impl ConfigDocument { mutated } - fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec { - let resolved: Vec = segments - .iter() - .map(|segment| (*segment).to_string()) - .collect(); - - if matches!(scope, Scope::Profile) - && resolved.first().is_none_or(|segment| segment != "profiles") - && let Some(profile) = self.profile.as_deref() - { - let mut scoped = Vec::with_capacity(resolved.len() + 2); - scoped.push("profiles".to_string()); - scoped.push(profile.to_string()); - scoped.extend(resolved); - return scoped; - } - - resolved - } - fn insert(&mut self, segments: &[String], value: TomlItem) -> bool { let Some((last, parents)) = segments.split_last() else { return false; @@ -1030,18 +999,13 @@ fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSele } /// Persist edits using a blocking strategy. -pub fn apply_blocking( - codex_home: &Path, - profile: Option<&str>, - edits: &[ConfigEdit], -) -> anyhow::Result<()> { +pub fn apply_blocking(codex_home: &Path, edits: &[ConfigEdit]) -> anyhow::Result<()> { let config_path = codex_home.join(CONFIG_TOML_FILE); - apply_blocking_to_resolved_file(&config_path, profile, edits) + apply_blocking_to_resolved_file(&config_path, edits) } fn apply_blocking_to_resolved_file( resolved_config_file: &Path, - legacy_profile: Option<&str>, edits: &[ConfigEdit], ) -> anyhow::Result<()> { if edits.is_empty() { @@ -1064,13 +1028,7 @@ fn apply_blocking_to_resolved_file( serialized.parse::()? }; - let profile = legacy_profile.map(ToOwned::to_owned).or_else(|| { - doc.get("profile") - .and_then(|item| item.as_str()) - .map(ToOwned::to_owned) - }); - - let mut document = ConfigDocument::new(doc, profile); + let mut document = ConfigDocument::new(doc); let mut mutated = false; for edit in edits { @@ -1093,29 +1051,18 @@ fn apply_blocking_to_resolved_file( /// Persist edits asynchronously by offloading the blocking writer. /// -/// `profile` selects a legacy `[profiles.]` section inside -/// `$CODEX_HOME/config.toml`; profile-v2 callers should resolve their target -/// file before constructing a [ConfigEditsBuilder]. -pub async fn apply( - codex_home: &Path, - profile: Option<&str>, - edits: Vec, -) -> anyhow::Result<()> { +pub async fn apply(codex_home: &Path, edits: Vec) -> anyhow::Result<()> { let codex_home = codex_home.to_path_buf(); let config_path = codex_home.join(CONFIG_TOML_FILE); - let profile = profile.map(ToOwned::to_owned); - task::spawn_blocking(move || { - apply_blocking_to_resolved_file(&config_path, profile.as_deref(), &edits) - }) - .await - .context("config persistence task panicked")? + task::spawn_blocking(move || apply_blocking_to_resolved_file(&config_path, &edits)) + .await + .context("config persistence task panicked")? } /// Fluent builder to batch config edits and apply them atomically. #[derive(Default)] pub struct ConfigEditsBuilder { config_path: PathBuf, - profile: Option, edits: Vec, } @@ -1136,16 +1083,10 @@ impl ConfigEditsBuilder { pub fn for_config_path(config_path: &Path) -> Self { Self { config_path: config_path.to_path_buf(), - profile: None, edits: Vec::new(), } } - pub fn with_profile(mut self, profile: Option<&str>) -> Self { - self.profile = profile.map(ToOwned::to_owned); - self - } - pub fn set_model(mut self, model: Option<&str>, effort: Option) -> Self { self.edits.push(ConfigEdit::SetModel { model: model.map(ToOwned::to_owned), @@ -1248,27 +1189,16 @@ impl ConfigEditsBuilder { /// Enable or disable a feature flag by key under the `[features]` table. /// - /// Disabling a default-false feature clears the root-scoped key instead of + /// Disabling a default-false feature clears the key instead of /// persisting `false`, so the config does not pin the feature once it - /// graduates to globally enabled. Profile-scoped disables still persist - /// `false` so they can override an inherited root enable. + /// graduates to globally enabled. pub fn set_feature_enabled(mut self, key: &str, enabled: bool) -> Self { - let profile_scoped = self.profile.is_some(); - let segments = if let Some(profile) = self.profile.as_ref() { - vec![ - "profiles".to_string(), - profile.clone(), - "features".to_string(), - key.to_string(), - ] - } else { - vec!["features".to_string(), key.to_string()] - }; + let segments = vec!["features".to_string(), key.to_string()]; let is_default_false_feature = FEATURES .iter() .find(|spec| spec.key == key) .is_some_and(|spec| !spec.default_enabled); - if enabled || profile_scoped || !is_default_false_feature { + if enabled || !is_default_false_feature { self.edits.push(ConfigEdit::SetPath { segments, value: value(enabled), @@ -1280,18 +1210,8 @@ impl ConfigEditsBuilder { } pub fn set_windows_sandbox_mode(mut self, mode: &str) -> Self { - let segments = if let Some(profile) = self.profile.as_ref() { - vec![ - "profiles".to_string(), - profile.clone(), - "windows".to_string(), - "sandbox".to_string(), - ] - } else { - vec!["windows".to_string(), "sandbox".to_string()] - }; self.edits.push(ConfigEdit::SetPath { - segments, + segments: vec!["windows".to_string(), "sandbox".to_string()], value: value(mode), }); self @@ -1339,34 +1259,15 @@ impl ConfigEditsBuilder { "elevated_windows_sandbox", "enable_experimental_windows_sandbox", ] { - let mut segments = vec!["features".to_string(), key.to_string()]; - if let Some(profile) = self.profile.as_ref() { - segments = vec![ - "profiles".to_string(), - profile.clone(), - "features".to_string(), - key.to_string(), - ]; - } + let segments = vec!["features".to_string(), key.to_string()]; self.edits.push(ConfigEdit::ClearPath { segments }); } self } pub fn set_session_picker_view(mut self, mode: SessionPickerViewMode) -> Self { - let segments = if let Some(profile) = self.profile.as_ref() { - vec![ - "profiles".to_string(), - profile.clone(), - "tui".to_string(), - "session_picker_view".to_string(), - ] - } else { - vec!["tui".to_string(), "session_picker_view".to_string()] - }; - self.edits.push(ConfigEdit::SetPath { - segments, + segments: vec!["tui".to_string(), "session_picker_view".to_string()], value: value(mode.to_string()), }); self @@ -1382,13 +1283,13 @@ impl ConfigEditsBuilder { /// Apply edits on a blocking thread. pub fn apply_blocking(self) -> anyhow::Result<()> { - apply_blocking_to_resolved_file(&self.config_path, self.profile.as_deref(), &self.edits) + apply_blocking_to_resolved_file(&self.config_path, &self.edits) } /// Apply edits asynchronously via a blocking offload. pub async fn apply(self) -> anyhow::Result<()> { task::spawn_blocking(move || { - apply_blocking_to_resolved_file(&self.config_path, self.profile.as_deref(), &self.edits) + apply_blocking_to_resolved_file(&self.config_path, &self.edits) }) .await .context("config persistence task panicked")? diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 740d819aed2d..dce192831b6d 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -20,7 +20,6 @@ fn blocking_set_model_top_level() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: Some("gpt-5.4".to_string()), effort: Some(ReasoningEffort::High), @@ -111,24 +110,6 @@ session_picker_view = "dense" assert_eq!(contents, expected); } -#[test] -fn session_picker_view_builder_respects_active_profile() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - - ConfigEditsBuilder::new(codex_home) - .with_profile(Some("work")) - .set_session_picker_view(SessionPickerViewMode::Dense) - .apply_blocking() - .expect("persist"); - - let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[profiles.work.tui] -session_picker_view = "dense" -"#; - assert_eq!(contents, expected); -} - #[test] fn keymap_binding_edit_writes_root_action_binding() { let tmp = tempdir().expect("tmpdir"); @@ -380,7 +361,7 @@ enabled = false } #[test] -fn blocking_set_model_preserves_inline_table_contents() { +fn blocking_set_model_ignores_inline_legacy_profile_contents() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); @@ -396,7 +377,6 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: Some("o4-mini".to_string()), effort: None, @@ -407,7 +387,12 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let value: TomlValue = toml::from_str(&raw).expect("parse config"); - // Ensure sandbox_mode is preserved under profiles.fast and model updated. + assert_eq!( + value.get("model").and_then(TomlValue::as_str), + Some("o4-mini") + ); + + // Legacy profile values stay untouched when root settings are updated. let profiles_tbl = value .get("profiles") .and_then(|v| v.as_table()) @@ -422,7 +407,7 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } ); assert_eq!( fast_tbl.get("model").and_then(|v| v.as_str()), - Some("o4-mini") + Some("gpt-4o") ); } @@ -441,7 +426,6 @@ fn blocking_set_model_writes_through_symlink_chain() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: Some("gpt-5.4".to_string()), effort: Some(ReasoningEffort::High), @@ -474,7 +458,6 @@ fn blocking_set_model_replaces_symlink_on_cycle() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: Some("gpt-5.4".to_string()), effort: None, @@ -513,7 +496,6 @@ network_access = false apply_blocking( codex_home, - /*profile*/ None, &[ ConfigEdit::SetPath { segments: vec![ @@ -553,7 +535,7 @@ network_access = true } #[test] -fn blocking_clear_model_removes_inline_table_entry() { +fn blocking_clear_model_does_not_follow_legacy_active_profile() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); @@ -568,7 +550,6 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: None, effort: Some(ReasoningEffort::High), @@ -579,15 +560,14 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"profile = "fast" -[profiles.fast] -sandbox_mode = "strict" +profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } model_reasoning_effort = "high" "#; assert_eq!(contents, expected); } #[test] -fn blocking_set_model_scopes_to_active_profile() { +fn blocking_set_model_does_not_follow_legacy_active_profile() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( @@ -602,7 +582,6 @@ model_reasoning_effort = "low" apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: Some("o5-preview".to_string()), effort: Some(ReasoningEffort::Minimal), @@ -612,39 +591,11 @@ model_reasoning_effort = "low" let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"profile = "team" - -[profiles.team] -model_reasoning_effort = "minimal" model = "o5-preview" -"#; - assert_eq!(contents, expected); -} - -#[test] -fn blocking_set_model_with_explicit_profile() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"[profiles."team a"] -model = "gpt-5.4" -"#, - ) - .expect("seed"); - - apply_blocking( - codex_home, - Some("team a"), - &[ConfigEdit::SetModel { - model: Some("o4-mini".to_string()), - effort: None, - }], - ) - .expect("persist"); +model_reasoning_effort = "minimal" - let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[profiles."team a"] -model = "o4-mini" +[profiles.team] +model_reasoning_effort = "low" "#; assert_eq!(contents, expected); } @@ -666,7 +617,6 @@ existing = "value" apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeHideFullAccessWarning(true)], ) .expect("persist"); @@ -696,7 +646,6 @@ existing = "value" apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeHideRateLimitModelNudge(true)], ) .expect("persist"); @@ -722,7 +671,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeHideModelMigrationPrompt( "hide_gpt5_1_migration_prompt".to_string(), true, @@ -751,7 +699,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeHideModelMigrationPrompt( "hide_gpt-5.1-codex-max_migration_prompt".to_string(), true, @@ -780,7 +727,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::RecordModelMigrationSeen { from: "gpt-5.2".to_string(), to: "gpt-5.4".to_string(), @@ -811,7 +757,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome( true, )], @@ -841,7 +786,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject( "/Users/alexsong/code/skills".to_string(), @@ -874,7 +818,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(1_760_000_000)], ) .expect("persist"); @@ -902,7 +845,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt( "/Users/alexsong/code/skills".to_string(), @@ -996,7 +938,6 @@ fn blocking_replace_mcp_servers_round_trips() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], ) .expect("persist"); @@ -1069,12 +1010,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() { }, ); - apply_blocking( - codex_home, - /*profile*/ None, - &[ConfigEdit::ReplaceMcpServers(servers)], - ) - .expect("persist"); + apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = "\ @@ -1129,12 +1065,7 @@ foo = { command = "cmd" } }, ); - apply_blocking( - codex_home, - /*profile*/ None, - &[ConfigEdit::ReplaceMcpServers(servers)], - ) - .expect("persist"); + apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] @@ -1184,12 +1115,7 @@ foo = { command = "cmd" } # keep me }, ); - apply_blocking( - codex_home, - /*profile*/ None, - &[ConfigEdit::ReplaceMcpServers(servers)], - ) - .expect("persist"); + apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] @@ -1238,12 +1164,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me }, ); - apply_blocking( - codex_home, - /*profile*/ None, - &[ConfigEdit::ReplaceMcpServers(servers)], - ) - .expect("persist"); + apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] @@ -1293,12 +1214,7 @@ foo = { command = "cmd" } }, ); - apply_blocking( - codex_home, - /*profile*/ None, - &[ConfigEdit::ReplaceMcpServers(servers)], - ) - .expect("persist"); + apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] @@ -1315,7 +1231,6 @@ fn blocking_clear_path_noop_when_missing() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::ClearPath { segments: vec!["missing".to_string()], }], @@ -1336,7 +1251,6 @@ fn blocking_set_path_updates_notifications() { let item = value(false); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetPath { segments: vec!["tui".to_string(), "notifications".to_string()], value: item, @@ -1518,7 +1432,6 @@ fn replace_mcp_servers_blocking_clears_table_when_empty() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(BTreeMap::new())], ) .expect("persist"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2392af0e5887..7044b9c6765c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -34,7 +34,6 @@ use codex_config::config_toml::validate_model_providers; use codex_config::loader::load_config_layers_state; use codex_config::loader::project_trust_key; use codex_config::permissions_toml::PermissionsToml; -use codex_config::profile_toml::ConfigProfile; use codex_config::sandbox_mode_requirement_for_permission_profile; use codex_config::types::ApprovalsReviewer; use codex_config::types::AuthCredentialsStoreMode; @@ -959,9 +958,6 @@ pub struct Config { /// When `true`, suppress warnings about unstable (under development) features. pub suppress_unstable_features_warning: bool, - /// The active profile name used to derive this `Config` (if any). - pub active_profile: Option, - /// The currently active project config, resolved by checking if cwd: /// is (1) part of a git repo, (2) a git worktree, or (3) just using the cwd pub active_project: ProjectConfig, @@ -2033,7 +2029,6 @@ fn resolve_permission_config_syntax( config_layer_stack: &ConfigLayerStack, cfg: &ConfigToml, sandbox_mode_override: Option, - profile_sandbox_mode: Option, ) -> Option { if sandbox_mode_override.is_some() { return Some(PermissionConfigSyntax::Legacy); @@ -2058,10 +2053,6 @@ fn resolve_permission_config_syntax( return Some(PermissionConfigSyntax::Profiles); } - if profile_sandbox_mode.is_some() { - return Some(PermissionConfigSyntax::Legacy); - } - let mut selection = None; for layer in config_layer_stack.get_layers( ConfigLayerStackOrdering::LowestPrecedenceFirst, @@ -2134,7 +2125,6 @@ pub struct ConfigOverrides { pub default_permissions: Option, pub model_provider: Option, pub service_tier: Option>, - pub config_profile: Option, pub codex_self_exe: Option, pub codex_linux_sandbox_exe: Option, pub main_execve_wrapper_exe: Option, @@ -2159,41 +2149,23 @@ fn dedupe_absolute_paths(paths: &mut Vec) { paths.retain(|path| seen.insert(path.clone())); } -/// Resolves the OSS provider from CLI override, profile config, or global config. +/// Resolves the OSS provider from CLI override or global config. /// Returns `None` if no provider is configured at any level. pub fn resolve_oss_provider( explicit_provider: Option<&str>, config_toml: &ConfigToml, - config_profile: Option, ) -> Option { if let Some(provider) = explicit_provider { // Explicit provider specified (e.g., via --local-provider) Some(provider.to_string()) } else { - // Check profile config first, then global config - let profile = config_toml.get_config_profile(config_profile).ok(); - if let Some(profile) = &profile { - // Check if profile has an oss provider - if let Some(profile_oss_provider) = &profile.oss_provider { - Some(profile_oss_provider.clone()) - } - // If not then check if the toml has an oss provider - else { - config_toml.oss_provider.clone() - } - } else { - config_toml.oss_provider.clone() - } + config_toml.oss_provider.clone() } } /// Resolve the web search mode from explicit config and feature flags. -fn resolve_web_search_mode( - config_toml: &ConfigToml, - config_profile: &ConfigProfile, - features: &Features, -) -> Option { - if let Some(mode) = config_profile.web_search.or(config_toml.web_search) { +fn resolve_web_search_mode(config_toml: &ConfigToml, features: &Features) -> Option { + if let Some(mode) = config_toml.web_search { return Some(mode); } if features.enabled(Feature::WebSearchCached) { @@ -2205,82 +2177,55 @@ fn resolve_web_search_mode( None } -fn resolve_web_search_config( - config_toml: &ConfigToml, - config_profile: &ConfigProfile, -) -> Option { - let base = config_toml +fn resolve_web_search_config(config_toml: &ConfigToml) -> Option { + config_toml .tools .as_ref() - .and_then(|tools| tools.web_search.as_ref()); - let profile = config_profile - .tools - .as_ref() - .and_then(|tools| tools.web_search.as_ref()); - - match (base, profile) { - (None, None) => None, - (Some(base), None) => Some(base.clone().into()), - (None, Some(profile)) => Some(profile.clone().into()), - (Some(base), Some(profile)) => Some(base.merge(profile).into()), - } + .and_then(|tools| tools.web_search.as_ref()) + .cloned() + .map(Into::into) } -fn resolve_multi_agent_v2_config( - config_toml: &ConfigToml, - config_profile: &ConfigProfile, -) -> MultiAgentV2Config { +fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config { let base = multi_agent_v2_toml_config(config_toml.features.as_ref()); - let profile = multi_agent_v2_toml_config(config_profile.features.as_ref()); let default = MultiAgentV2Config::default(); - let max_concurrent_threads_per_session = profile + let max_concurrent_threads_per_session = base .and_then(|config| config.max_concurrent_threads_per_session) - .or_else(|| base.and_then(|config| config.max_concurrent_threads_per_session)) .unwrap_or(default.max_concurrent_threads_per_session); - let min_wait_timeout_ms = profile + let min_wait_timeout_ms = base .and_then(|config| config.min_wait_timeout_ms) - .or_else(|| base.and_then(|config| config.min_wait_timeout_ms)) .unwrap_or(default.min_wait_timeout_ms); - let max_wait_timeout_ms = profile + let max_wait_timeout_ms = base .and_then(|config| config.max_wait_timeout_ms) - .or_else(|| base.and_then(|config| config.max_wait_timeout_ms)) .unwrap_or(default.max_wait_timeout_ms); - let default_wait_timeout_ms = profile + let default_wait_timeout_ms = base .and_then(|config| config.default_wait_timeout_ms) - .or_else(|| base.and_then(|config| config.default_wait_timeout_ms)) .unwrap_or(default.default_wait_timeout_ms); - let usage_hint_enabled = profile + let usage_hint_enabled = base .and_then(|config| config.usage_hint_enabled) - .or_else(|| base.and_then(|config| config.usage_hint_enabled)) .unwrap_or(default.usage_hint_enabled); - let usage_hint_text = profile + let usage_hint_text = base .and_then(|config| config.usage_hint_text.as_ref()) - .or_else(|| base.and_then(|config| config.usage_hint_text.as_ref())) .cloned() .or(default.usage_hint_text); - let root_agent_usage_hint_text = profile + let root_agent_usage_hint_text = base .and_then(|config| config.root_agent_usage_hint_text.as_ref()) - .or_else(|| base.and_then(|config| config.root_agent_usage_hint_text.as_ref())) .cloned() .or(default.root_agent_usage_hint_text); - let subagent_usage_hint_text = profile + let subagent_usage_hint_text = base .and_then(|config| config.subagent_usage_hint_text.as_ref()) - .or_else(|| base.and_then(|config| config.subagent_usage_hint_text.as_ref())) .cloned() .or(default.subagent_usage_hint_text); - let tool_namespace = profile + let tool_namespace = base .and_then(|config| config.tool_namespace.as_ref()) - .or_else(|| base.and_then(|config| config.tool_namespace.as_ref())) .cloned() .or(default.tool_namespace); - let hide_spawn_agent_metadata = profile + let hide_spawn_agent_metadata = base .and_then(|config| config.hide_spawn_agent_metadata) - .or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata)) .unwrap_or(default.hide_spawn_agent_metadata); - let non_code_mode_only = profile + let non_code_mode_only = base .and_then(|config| config.non_code_mode_only) - .or_else(|| base.and_then(|config| config.non_code_mode_only)) .unwrap_or(default.non_code_mode_only); MultiAgentV2Config { @@ -2495,6 +2440,7 @@ impl Config { permission_profile: mut constrained_permission_profile, web_search_mode: mut constrained_web_search_mode, allow_managed_hooks_only: _, + allow_appshots: _, computer_use: _, feature_requirements, managed_hooks: _, @@ -2531,7 +2477,6 @@ impl Config { default_permissions: default_permissions_override, model_provider, service_tier: service_tier_override, - config_profile: config_profile_key, codex_self_exe, codex_linux_sandbox_exe, main_execve_wrapper_exe, @@ -2574,24 +2519,15 @@ impl Config { "`permission_profile` and `default_permissions` overrides cannot both be set", )); } + if let Some(profile) = cfg.profile.as_deref() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "legacy `profile = \"{profile}\"` config is no longer supported; use `--profile {profile}` with `{profile}.config.toml` instead" + ), + )); + } - let active_profile_name = config_profile_key - .as_ref() - .or(cfg.profile.as_ref()) - .cloned(); - let config_profile = match active_profile_name.as_ref() { - Some(key) => cfg - .profiles - .get(key) - .ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("config profile `{key}` not found"), - ) - })? - .clone(), - None => ConfigProfile::default(), - }; let tool_suggest = resolve_tool_suggest_config(&cfg, &config_layer_stack); let feature_overrides = FeatureOverrides { web_search_request: override_tools_web_search_request, @@ -2603,9 +2539,7 @@ impl Config { experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, }, FeatureConfigSource { - features: config_profile.features.as_ref(), - experimental_use_unified_exec_tool: config_profile - .experimental_use_unified_exec_tool, + ..Default::default() }, feature_overrides, ); @@ -2615,9 +2549,8 @@ impl Config { &mut startup_warnings, )?; let enable_network_proxy = features.enabled(Feature::NetworkProxy); - let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg, &config_profile); - let windows_sandbox_private_desktop = - resolve_windows_sandbox_private_desktop(&cfg, &config_profile); + let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg); + let windows_sandbox_private_desktop = resolve_windows_sandbox_private_desktop(&cfg); let resolved_cwd = AbsolutePathBuf::try_from(normalize_for_native_workdir({ use std::env; @@ -2651,7 +2584,6 @@ impl Config { &config_layer_stack, &cfg, sandbox_mode, - config_profile.sandbox_mode, ); let requirements_toml = config_layer_stack.requirements_toml(); let effective_permission_selection = resolve_effective_permission_selection( @@ -2908,7 +2840,7 @@ impl Config { let mut permission_profile = cfg .derive_permission_profile( sandbox_mode, - config_profile.sandbox_mode, + /*profile_sandbox_mode*/ None, windows_sandbox_level, Some(&active_project), Some(&constrained_permission_profile), @@ -2965,20 +2897,11 @@ impl Config { network_proxy, ); } - if let Some(network_proxy) = network_proxy_toml_config(config_profile.features.as_ref()) - { - apply_network_proxy_feature_config( - &mut configured_network_proxy_config, - network_proxy, - ); - } configured_network_proxy_config.network.enabled = true; } - let approval_policy_was_explicit = approval_policy_override.is_some() - || config_profile.approval_policy.is_some() - || cfg.approval_policy.is_some(); + let approval_policy_was_explicit = + approval_policy_override.is_some() || cfg.approval_policy.is_some(); let mut approval_policy = approval_policy_override - .or(config_profile.approval_policy) .or(cfg.approval_policy) .unwrap_or_else(|| { if active_project.is_trusted() { @@ -2998,11 +2921,9 @@ impl Config { ); approval_policy = constrained_approval_policy.value(); } - let approvals_reviewer_was_explicit = approvals_reviewer_override.is_some() - || config_profile.approvals_reviewer.is_some() - || cfg.approvals_reviewer.is_some(); + let approvals_reviewer_was_explicit = + approvals_reviewer_override.is_some() || cfg.approvals_reviewer.is_some(); let mut approvals_reviewer = approvals_reviewer_override - .or(config_profile.approvals_reviewer) .or(cfg.approvals_reviewer) .unwrap_or(ApprovalsReviewer::User); if !approvals_reviewer_was_explicit @@ -3014,16 +2935,13 @@ impl Config { ); approvals_reviewer = constrained_approvals_reviewer.value(); } - let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features) - .unwrap_or(WebSearchMode::Cached); - let web_search_config = resolve_web_search_config(&cfg, &config_profile); - let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg, &config_profile); + let web_search_mode = + resolve_web_search_mode(&cfg, &features).unwrap_or(WebSearchMode::Cached); + let web_search_config = resolve_web_search_config(&cfg); + let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg); let apps_mcp_path_override = if features.enabled(Feature::AppsMcpPathOverride) { let base = apps_mcp_path_override_toml_config(cfg.features.as_ref()); - let profile = apps_mcp_path_override_toml_config(config_profile.features.as_ref()); - profile - .and_then(|config| config.path.as_ref()) - .or_else(|| base.and_then(|config| config.path.as_ref())) + base.and_then(|config| config.path.as_ref()) .cloned() .or_else(|| Some("/ps/mcp".to_string())) } else { @@ -3045,7 +2963,6 @@ impl Config { .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidData, message))?; let model_provider_id = model_provider - .or(config_profile.model_provider) .or(cfg.model_provider) .unwrap_or_else(|| "openai".to_string()); let model_provider = model_providers @@ -3207,12 +3124,12 @@ impl Config { let forced_login_method = cfg.forced_login_method; - let model = model.or(config_profile.model).or(cfg.model); + let model = model.or(cfg.model); let notices = cfg.notice.unwrap_or_default(); let service_tier = match service_tier_override { Some(Some(service_tier)) => Some(service_tier), Some(None) => Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()), - None => config_profile.service_tier.or(cfg.service_tier), + None => cfg.service_tier, }; let service_tier = service_tier.and_then(|service_tier| { match ServiceTier::from_request_value(&service_tier) { @@ -3236,10 +3153,7 @@ impl Config { // Load base instructions override from a file if specified. If the // path is relative, resolve it against the effective cwd so the // behaviour matches other path-like config values. - let model_instructions_path = config_profile - .model_instructions_file - .as_ref() - .or(cfg.model_instructions_file.as_ref()); + let model_instructions_path = cfg.model_instructions_file.as_ref(); let file_base_instructions = Self::try_read_non_empty_file( fs, model_instructions_path, @@ -3250,27 +3164,16 @@ impl Config { .or(file_base_instructions) .or(cfg.instructions.clone()); let developer_instructions = developer_instructions.or(cfg.developer_instructions); - let include_permissions_instructions = config_profile - .include_permissions_instructions - .or(cfg.include_permissions_instructions) - .unwrap_or(true); - let include_apps_instructions = config_profile - .include_apps_instructions - .or(cfg.include_apps_instructions) - .unwrap_or(true); - let include_collaboration_mode_instructions = config_profile - .include_collaboration_mode_instructions - .or(cfg.include_collaboration_mode_instructions) - .unwrap_or(true); + let include_permissions_instructions = cfg.include_permissions_instructions.unwrap_or(true); + let include_apps_instructions = cfg.include_apps_instructions.unwrap_or(true); + let include_collaboration_mode_instructions = + cfg.include_collaboration_mode_instructions.unwrap_or(true); let include_skill_instructions = cfg .skills .as_ref() .and_then(|skills| skills.include_instructions) .unwrap_or(true); - let include_environment_context = config_profile - .include_environment_context - .or(cfg.include_environment_context) - .unwrap_or(true); + let include_environment_context = cfg.include_environment_context.unwrap_or(true); let guardian_policy_config = guardian_policy_config_from_requirements(config_layer_stack.requirements_toml()) .or_else(|| { @@ -3281,7 +3184,6 @@ impl Config { )) }); let personality = personality - .or(config_profile.personality) .or(cfg.personality) .or_else(|| { features @@ -3289,10 +3191,7 @@ impl Config { .then_some(Personality::Pragmatic) }); - let experimental_compact_prompt_path = config_profile - .experimental_compact_prompt_file - .as_ref() - .or(cfg.experimental_compact_prompt_file.as_ref()); + let experimental_compact_prompt_path = cfg.experimental_compact_prompt_file.as_ref(); let file_compact_prompt = Self::try_read_non_empty_file( fs, experimental_compact_prompt_path, @@ -3300,19 +3199,12 @@ impl Config { ) .await?; let compact_prompt = compact_prompt.or(file_compact_prompt); - let zsh_path = zsh_path_override - .or(config_profile.zsh_path.map(Into::into)) - .or(cfg.zsh_path.map(Into::into)); + let zsh_path = zsh_path_override.or(cfg.zsh_path.map(Into::into)); let review_model = override_review_model.or(cfg.review_model); let check_for_update_on_startup = cfg.check_for_update_on_startup.unwrap_or(true); - let model_catalog = load_model_catalog( - config_profile - .model_catalog_json - .clone() - .or(cfg.model_catalog_json.clone()), - )?; + let model_catalog = load_model_catalog(cfg.model_catalog_json.clone())?; let log_dir = cfg .log_dir @@ -3558,21 +3450,14 @@ impl Config { .or(show_raw_agent_reasoning) .unwrap_or(false), guardian_policy_config, - model_reasoning_effort: config_profile - .model_reasoning_effort - .or(cfg.model_reasoning_effort), - plan_mode_reasoning_effort: config_profile - .plan_mode_reasoning_effort - .or(cfg.plan_mode_reasoning_effort), - model_reasoning_summary: config_profile - .model_reasoning_summary - .or(cfg.model_reasoning_summary), + model_reasoning_effort: cfg.model_reasoning_effort, + plan_mode_reasoning_effort: cfg.plan_mode_reasoning_effort, + model_reasoning_summary: cfg.model_reasoning_summary, model_supports_reasoning_summaries: cfg.model_supports_reasoning_summaries, model_catalog, - model_verbosity: config_profile.model_verbosity.or(cfg.model_verbosity), - chatgpt_base_url: config_profile + model_verbosity: cfg.model_verbosity, + chatgpt_base_url: cfg .chatgpt_base_url - .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), apps_mcp_path_override, apps_mcp_product_sku: cfg.apps_mcp_product_sku.clone(), @@ -3612,16 +3497,11 @@ impl Config { suppress_unstable_features_warning: cfg .suppress_unstable_features_warning .unwrap_or(false), - active_profile: active_profile_name, active_project, notices, check_for_update_on_startup, disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), - analytics_enabled: config_profile - .analytics - .as_ref() - .and_then(|a| a.enabled) - .or(cfg.analytics.as_ref().and_then(|a| a.enabled)), + analytics_enabled: cfg.analytics.as_ref().and_then(|a| a.enabled), feedback_enabled: cfg .feedback .as_ref() @@ -3669,11 +3549,10 @@ impl Config { .as_ref() .map(|t| t.pet_anchor) .unwrap_or_default(), - tui_session_picker_view: config_profile + tui_session_picker_view: cfg .tui .as_ref() .and_then(|t| t.session_picker_view) - .or_else(|| cfg.tui.as_ref().and_then(|t| t.session_picker_view)) .unwrap_or_default(), terminal_resize_reflow, tui_keymap: cfg diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 6a0553548860..e5f6e4132dae 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -126,6 +126,11 @@ impl ContextManager { &self.items } + /// Returns raw items in the history and consumes the snapshot. + pub(crate) fn into_raw_items(self) -> Vec { + self.items + } + pub(crate) fn history_version(&self) -> u64 { self.history_version } diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 047299f48eb9..6452e9d5ad65 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -16,6 +16,7 @@ use codex_hooks::SessionStartOutcome; use codex_hooks::StartHookTarget; use codex_hooks::StopHookTarget; use codex_hooks::StopOutcome; +use codex_hooks::SubagentHookContext; use codex_hooks::UserPromptSubmitOutcome; use codex_hooks::UserPromptSubmitRequest; use codex_otel::HOOK_RUN_DURATION_METRIC; @@ -111,13 +112,11 @@ pub(crate) async fn run_pending_session_start_hooks( codex_hooks::SessionStartSource::Startup ) => { - let agent_type = agent_role - .clone() - .unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string()); + let context = subagent_hook_context(sess, agent_role); StartHookTarget::SubagentStart { turn_id: turn_context.sub_id.clone(), - agent_id: sess.thread_id().to_string(), - agent_type, + agent_id: context.agent_id, + agent_type: context.agent_type, } } SessionSource::SubAgent(_) => return false, @@ -168,6 +167,7 @@ pub(crate) async fn run_pre_tool_use_hooks( let request = PreToolUseRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, @@ -228,6 +228,7 @@ pub(crate) async fn run_permission_request_hooks( let request = PermissionRequestRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, @@ -269,6 +270,7 @@ pub(crate) async fn run_post_tool_use_hooks( let request = PostToolUseRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, @@ -303,9 +305,7 @@ pub(crate) async fn run_turn_stop_hooks( parent_thread_id, .. }) => { - let agent_type = agent_role - .clone() - .unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string()); + let context = subagent_hook_context(sess, agent_role); let agent_transcript_path = sess.hook_transcript_path().await; let parent_transcript_path = match sess .services @@ -329,8 +329,8 @@ pub(crate) async fn run_turn_stop_hooks( }; ( StopHookTarget::SubagentStop { - agent_id: sess.thread_id().to_string(), - agent_type, + agent_id: context.agent_id, + agent_type: context.agent_type, agent_transcript_path, }, parent_transcript_path, @@ -369,6 +369,7 @@ pub(crate) async fn run_pre_compact_hooks( let request = codex_hooks::PreCompactRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, @@ -407,6 +408,7 @@ pub(crate) async fn run_post_compact_hooks( let request = codex_hooks::PostCompactRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, @@ -502,6 +504,7 @@ pub(crate) async fn inspect_pending_input( let request = UserPromptSubmitRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, @@ -728,6 +731,27 @@ fn hook_permission_mode(turn_context: &TurnContext) -> String { .to_string() } +fn thread_spawn_subagent_hook_context( + sess: &Arc, + turn_context: &TurnContext, +) -> Option { + match &turn_context.session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) => { + Some(subagent_hook_context(sess, agent_role)) + } + _ => None, + } +} + +fn subagent_hook_context(sess: &Arc, agent_role: &Option) -> SubagentHookContext { + SubagentHookContext { + agent_id: sess.thread_id().to_string(), + agent_type: agent_role + .clone() + .unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string()), + } +} + fn compaction_trigger_label(value: CompactionTrigger) -> &'static str { match value { CompactionTrigger::Manual => "manual", diff --git a/codex-rs/core/src/installation_id.rs b/codex-rs/core/src/installation_id.rs index a42e6b6d8353..a87c660f6132 100644 --- a/codex-rs/core/src/installation_id.rs +++ b/codex-rs/core/src/installation_id.rs @@ -11,6 +11,9 @@ use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::PermissionsExt; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_file_lock::FileLockOutcome; +use codex_utils_file_lock::acquire_sibling_lock_dir; +use codex_utils_file_lock::lock_exclusive_optional; use tokio::fs; use uuid::Uuid; @@ -29,7 +32,10 @@ pub async fn resolve_installation_id(codex_home: &AbsolutePathBuf) -> Result None, + FileLockOutcome::Unsupported => Some(acquire_sibling_lock_dir(&path)?), + }; #[cfg(unix)] { diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 3d57bd707491..b1e36b034724 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -460,16 +460,8 @@ pub async fn reload_user_config(sess: &Arc) { pub async fn compact(sess: &Arc, sub_id: String) { let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; - sess.spawn_task( - Arc::clone(&turn_context), - vec![UserInput::Text { - text: turn_context.compact_prompt().to_string(), - // Compaction prompt is synthesized; no UI element ranges to preserve. - text_elements: Vec::new(), - }], - CompactTask, - ) - .await; + sess.spawn_task(Arc::clone(&turn_context), Vec::new(), CompactTask) + .await; } pub async fn thread_rollback(sess: &Arc, sub_id: String, num_turns: u32) { diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index fc2811710ab4..11b8651ae6b9 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -122,6 +122,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -189,6 +190,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -218,6 +220,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -289,6 +292,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -318,6 +322,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -381,6 +386,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -410,6 +416,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -438,6 +445,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -501,6 +509,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -530,6 +539,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -590,6 +600,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -643,6 +654,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -671,6 +683,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -716,6 +729,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -743,6 +757,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -875,6 +890,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -939,6 +955,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1043,6 +1060,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1070,6 +1088,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1151,6 +1170,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1178,6 +1198,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1268,6 +1289,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1295,6 +1317,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1346,6 +1369,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1420,6 +1444,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1447,6 +1472,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -1470,6 +1496,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 8ed010172e76..6ef8aad91e35 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -816,7 +816,6 @@ impl Session { .permissions .legacy_sandbox_policy(session_configuration.cwd.as_path()), mcp_servers.keys().map(String::as_str).collect(), - config.active_profile.clone(), ); let use_zsh_fork_shell = config.features.enabled(Feature::ShellZshFork); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index d4d0c286608f..30d26191d741 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -267,8 +267,24 @@ fn skill_message(text: &str) -> ResponseItem { } #[tokio::test] -async fn regular_turn_emits_turn_started_without_waiting_for_startup_prewarm() { - let (sess, tc, rx) = make_session_and_context_with_rx().await; +async fn regular_turn_emits_turn_started_with_trace_id_without_waiting_for_startup_prewarm() { + let _trace_test_context = install_test_tracing("codex-core-tests"); + let request_parent = W3cTraceContext { + traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()), + tracestate: Some("vendor=value".into()), + }; + let request_span = info_span!("app_server.request"); + assert!(set_parent_from_w3c_trace_context( + &request_span, + &request_parent + )); + let (sess, tc, rx) = make_session_and_context_with_rx() + .instrument(request_span) + .await; + assert_eq!( + tc.trace_id.as_deref(), + Some("00000000000000000000000000000011") + ); let (_tx, startup_prewarm_rx) = tokio::sync::oneshot::channel::<()>(); let handle = tokio::spawn(async move { let _ = startup_prewarm_rx.await; @@ -294,10 +310,11 @@ async fn regular_turn_emits_turn_started_without_waiting_for_startup_prewarm() { .await .expect("expected turn started event without waiting for startup prewarm") .expect("channel open"); - assert!(matches!( - first.msg, - EventMsg::TurnStarted(TurnStartedEvent { turn_id, .. }) if turn_id == tc.sub_id - )); + let EventMsg::TurnStarted(turn_started) = first.msg else { + panic!("expected turn started event"); + }; + assert_eq!(turn_started.turn_id, tc.sub_id); + assert_eq!(turn_started.trace_id, tc.trace_id); sess.abort_all_tasks(TurnAbortReason::Interrupted).await; } @@ -2378,6 +2395,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2573,6 +2591,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2600,6 +2619,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2684,6 +2704,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2709,6 +2730,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2728,6 +2750,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2783,6 +2806,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2808,6 +2832,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -2833,6 +2858,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + trace_id: None, started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, @@ -5712,7 +5738,7 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() { self: Arc, _session: Arc, _ctx: Arc, - _input: Vec, + _input: Vec, _cancellation_token: CancellationToken, ) -> Option { let mut trace = self @@ -7788,7 +7814,7 @@ impl SessionTask for NeverEndingTask { self: Arc, _session: Arc, _ctx: Arc, - _input: Vec, + _input: Vec, cancellation_token: CancellationToken, ) -> Option { if self.listen_to_cancellation_token { @@ -7817,7 +7843,7 @@ impl SessionTask for GuardianDeniedApprovalTask { self: Arc, session: Arc, ctx: Arc, - _input: Vec, + _input: Vec, cancellation_token: CancellationToken, ) -> Option { let session = session.clone_session(); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 26ba845460c3..5a2ed71dda0c 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -114,8 +114,8 @@ use tracing::trace; use tracing::trace_span; use tracing::warn; -/// Takes a user message as input and runs a loop where, at each sampling request, the model -/// replies with either: +/// Takes initial turn input and runs a loop where, at each sampling request, +/// the model replies with either: /// /// - requested function calls /// - an assistant message @@ -132,7 +132,7 @@ pub(crate) async fn run_turn( sess: Arc, turn_context: Arc, turn_extension_data: Arc, - input: Vec, + input: Vec, prewarmed_client_session: Option, cancellation_token: CancellationToken, ) -> Option { @@ -142,25 +142,18 @@ pub(crate) async fn run_turn( // new user message are recorded. Estimate pending incoming items (context // diffs/full reinjection + user input) and trigger compaction preemptively // when they would push the thread over the compaction threshold. - let pre_sampling_compact = - match run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await { - Ok(pre_sampling_compact) => pre_sampling_compact, - Err(err) => { - if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded - && let Err(err) = sess - .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: turn_context.as_ref(), - }) - .await - { - warn!("failed to usage-limit active goal after usage-limit error: {err}"); - } - error!("Failed to run pre-sampling compact"); - return None; - } - }; - if pre_sampling_compact.reset_client_session { - client_session.reset_websocket_session(); + if let Err(err) = run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await { + if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + && let Err(err) = sess + .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: turn_context.as_ref(), + }) + .await + { + warn!("failed to usage-limit active goal after usage-limit error: {err}"); + } + error!("Failed to run pre-sampling compact"); + return None; } sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref()) @@ -172,26 +165,9 @@ pub(crate) async fn run_turn( if run_pending_session_start_hooks(&sess, &turn_context).await { return None; } - if !input.is_empty() { - let initial_turn_input = TurnInput::UserInput(input.clone()); - let user_prompt_submit_outcome = - inspect_pending_input(&sess, &turn_context, &initial_turn_input).await; - if user_prompt_submit_outcome.should_stop { - record_additional_contexts( - &sess, - &turn_context, - user_prompt_submit_outcome.additional_contexts, - ) - .await; - return None; - } - record_pending_input( - &sess, - &turn_context, - initial_turn_input, - user_prompt_submit_outcome.additional_contexts, - ) - .await; + let mut can_drain_pending_input = input.is_empty(); + if run_hooks_and_record_inputs(&sess, &turn_context, &input).await { + return None; } sess.merge_connector_selection(explicitly_enabled_connectors.clone()) @@ -232,9 +208,8 @@ pub(crate) async fn run_turn( // one instance across retries within this turn. // Pending input is drained into history before building the next model request. // However, we defer that drain until after sampling in two cases: - // 1. At the start of a turn, so the fresh user prompt in `input` gets sampled first. + // 1. At the start of a turn, so the fresh turn input in `input` gets sampled first. // 2. After auto-compact, when model/tool continuation needs to resume before any steer. - let mut can_drain_pending_input = input.is_empty(); loop { // Note that pending_input would be something like a message the user @@ -246,27 +221,7 @@ pub(crate) async fn run_turn( Vec::new() }; - let mut blocked_pending_input = false; - let mut accepted_pending_input = false; - for pending_input_item in pending_input { - let hook_outcome = - inspect_pending_input(&sess, &turn_context, &pending_input_item).await; - if hook_outcome.should_stop { - blocked_pending_input = true; - record_additional_contexts(&sess, &turn_context, hook_outcome.additional_contexts) - .await; - } else { - accepted_pending_input = true; - record_pending_input( - &sess, - &turn_context, - pending_input_item, - hook_outcome.additional_contexts, - ) - .await; - } - } - if blocked_pending_input && !accepted_pending_input { + if run_hooks_and_record_inputs(&sess, &turn_context, &pending_input).await { break; } @@ -325,7 +280,7 @@ pub(crate) async fn run_turn( // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. if token_limit_reached && needs_follow_up { - let reset_client_session = match run_auto_compact( + if let Err(err) = run_auto_compact( &sess, &turn_context, &mut client_session, @@ -335,24 +290,18 @@ pub(crate) async fn run_turn( ) .await { - Ok(reset_client_session) => reset_client_session, - Err(err) => { - if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded - && let Err(err) = sess - .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: turn_context.as_ref(), - }) - .await - { - warn!( - "failed to usage-limit active goal after usage-limit error: {err}" - ); - } - return None; + if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + && let Err(err) = sess + .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: turn_context.as_ref(), + }) + .await + { + warn!( + "failed to usage-limit active goal after usage-limit error: {err}" + ); } - }; - if reset_client_session { - client_session.reset_websocket_session(); + return None; } can_drain_pending_input = !model_needs_follow_up; continue; @@ -450,6 +399,32 @@ pub(crate) async fn run_turn( last_agent_message } +async fn run_hooks_and_record_inputs( + sess: &Arc, + turn_context: &Arc, + input: &[TurnInput], +) -> bool { + let mut blocked_input = false; + let mut accepted_input = false; + for input_item in input { + let hook_outcome = inspect_pending_input(sess, turn_context, input_item).await; + if hook_outcome.should_stop { + blocked_input = true; + record_additional_contexts(sess, turn_context, hook_outcome.additional_contexts).await; + } else { + accepted_input = true; + record_pending_input( + sess, + turn_context, + input_item.clone(), + hook_outcome.additional_contexts, + ) + .await; + } + } + blocked_input && !accepted_input +} + #[expect( clippy::await_holding_invalid_type, reason = "MCP tool listing borrows the read guard across cancellation-aware await" @@ -457,9 +432,18 @@ pub(crate) async fn run_turn( async fn build_skills_and_plugins( sess: &Arc, turn_context: &TurnContext, - input: &[UserInput], + input: &[TurnInput], cancellation_token: &CancellationToken, ) -> Option<(Vec, HashSet)> { + let user_input = input + .iter() + .filter_map(|item| match item { + TurnInput::UserInput(content) => Some(content.as_slice()), + TurnInput::ResponseInputItem(_) => None, + }) + .flatten() + .cloned() + .collect::>(); let tracking = build_track_events_context( turn_context.model_info.slug.clone(), sess.conversation_id.to_string(), @@ -473,7 +457,7 @@ async fn build_skills_and_plugins( // Structured plugin:// mentions are resolved from the current session's // enabled plugins, then converted into turn-scoped guidance below. let mentioned_plugins = - collect_explicit_plugin_mentions(input, loaded_plugins.capability_summaries()); + collect_explicit_plugin_mentions(&user_input, loaded_plugins.capability_summaries()); let mcp_tools = if turn_context.apps_enabled() || !mentioned_plugins.is_empty() { // Plugin mentions need raw MCP/app inventory even when app tools // are normally hidden so we can describe the plugin's currently @@ -511,7 +495,7 @@ async fn build_skills_and_plugins( let skill_name_counts_lower = build_skill_name_counts(&skills_outcome.skills, &skills_outcome.disabled_paths).1; let mentioned_skills = collect_explicit_skill_mentions( - input, + &user_input, &skills_outcome.skills, &skills_outcome.disabled_paths, &connector_slug_counts, @@ -553,7 +537,7 @@ async fn build_skills_and_plugins( ); let plugin_items = build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors); - let mut explicitly_enabled_connectors = collect_explicit_app_ids(input); + let mut explicitly_enabled_connectors = collect_explicit_app_ids(&user_input); explicitly_enabled_connectors.extend(skill_connector_ids); let connector_names_by_id = available_connectors .iter() @@ -589,7 +573,7 @@ async fn build_skills_and_plugins( async fn track_turn_resolved_config_analytics( sess: &Session, turn_context: &TurnContext, - input: &[UserInput], + input: &[TurnInput], ) { let thread_config = { let state = sess.state.lock().await; @@ -606,6 +590,11 @@ async fn track_turn_resolved_config_analytics( thread_id: sess.conversation_id.to_string(), num_input_images: input .iter() + .filter_map(|item| match item { + TurnInput::UserInput(content) => Some(content.as_slice()), + TurnInput::ResponseInputItem(_) => None, + }) + .flatten() .filter(|item| { matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. }) }) @@ -634,10 +623,6 @@ async fn track_turn_resolved_config_analytics( }); } -struct PreSamplingCompactResult { - reset_client_session: bool, -} - #[derive(Debug)] struct AutoCompactTokenStatus { // Full active context usage, independent of the configured auto-compact scope. @@ -708,14 +693,12 @@ async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, client_session: &mut ModelClientSession, -) -> CodexResult { - let mut pre_sampling_compacted = - maybe_run_previous_model_inline_compact(sess, turn_context, client_session).await?; - let mut reset_client_session = pre_sampling_compacted; +) -> CodexResult<()> { + maybe_run_previous_model_inline_compact(sess, turn_context, client_session).await?; let token_status = auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await; // Compact if the configured auto-compaction budget or usable context window is exhausted. if token_status.token_limit_reached { - reset_client_session |= run_auto_compact( + run_auto_compact( sess, turn_context, client_session, @@ -724,26 +707,21 @@ async fn run_pre_sampling_compact( CompactionPhase::PreTurn, ) .await?; - pre_sampling_compacted = true; } - Ok(PreSamplingCompactResult { - reset_client_session: pre_sampling_compacted && reset_client_session, - }) + Ok(()) } /// Runs pre-sampling compaction against the previous model when switching to a smaller /// context-window model. /// -/// Returns `Ok(true)` when compaction ran successfully, `Ok(false)` when compaction was skipped -/// because the model/context-window preconditions were not met, and `Err(_)` only when compaction -/// was attempted and failed. +/// Returns `Err(_)` only when compaction was attempted and failed. async fn maybe_run_previous_model_inline_compact( sess: &Arc, turn_context: &Arc, client_session: &mut ModelClientSession, -) -> CodexResult { +) -> CodexResult<()> { let Some(previous_turn_settings) = sess.previous_turn_settings().await else { - return Ok(false); + return Ok(()); }; let previous_model_turn_context = Arc::new( turn_context @@ -752,10 +730,10 @@ async fn maybe_run_previous_model_inline_compact( ); let Some(old_context_window) = previous_model_turn_context.model_context_window() else { - return Ok(false); + return Ok(()); }; let Some(new_context_window) = turn_context.model_context_window() else { - return Ok(false); + return Ok(()); }; let active_context_tokens = sess.get_total_token_usage().await; let previous_model_limit_reached = match turn_context @@ -776,7 +754,7 @@ async fn maybe_run_previous_model_inline_compact( && previous_model_turn_context.model_info.slug != turn_context.model_info.slug && old_context_window > new_context_window; if should_run { - let _ = run_auto_compact( + run_auto_compact( sess, &previous_model_turn_context, client_session, @@ -785,9 +763,8 @@ async fn maybe_run_previous_model_inline_compact( CompactionPhase::PreTurn, ) .await?; - return Ok(true); } - Ok(false) + Ok(()) } async fn run_auto_compact( @@ -797,7 +774,7 @@ async fn run_auto_compact( initial_context_injection: InitialContextInjection, reason: CompactionReason, phase: CompactionPhase, -) -> CodexResult { +) -> CodexResult<()> { if should_use_remote_compact_task(turn_context.provider.info()) { if turn_context.features.enabled(Feature::RemoteCompactionV2) { run_inline_remote_auto_compact_task_v2( @@ -809,7 +786,7 @@ async fn run_auto_compact( phase, ) .await?; - return Ok(false); + return Ok(()); } run_inline_remote_auto_compact_task( Arc::clone(sess), @@ -829,7 +806,7 @@ async fn run_auto_compact( ) .await?; } - Ok(true) + Ok(()) } pub(super) fn collect_explicit_app_ids_from_skill_items( diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index dddf46391ed5..77914633a204 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use super::SessionTask; use super::SessionTaskContext; +use crate::session::TurnInput; use crate::session::turn_context::TurnContext; use crate::state::TaskKind; use codex_protocol::user_input::UserInput; @@ -23,7 +24,7 @@ impl SessionTask for CompactTask { self: Arc, session: Arc, ctx: Arc, - input: Vec, + _input: Vec, _cancellation_token: CancellationToken, ) -> Option { let session = session.clone_session(); @@ -47,6 +48,11 @@ impl SessionTask for CompactTask { /*inc*/ 1, &[("type", "local")], ); + let input = vec![UserInput::Text { + text: ctx.compact_prompt().to_string(), + // Compaction prompt is synthesized; no UI element ranges to preserve. + text_elements: Vec::new(), + }]; crate::compact::run_compact_task(session.clone(), ctx, input).await }; None diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 9cb6b5f0f20e..4d6db8acf22f 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -214,7 +214,7 @@ pub(crate) trait SessionTask: Send + Sync + 'static { self: Arc, session: Arc, ctx: Arc, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> impl std::future::Future> + Send; @@ -245,7 +245,7 @@ pub(crate) trait AnySessionTask: Send + Sync + 'static { self: Arc, session: Arc, ctx: Arc, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> BoxFuture<'static, Option>; @@ -276,7 +276,7 @@ where self: Arc, session: Arc, ctx: Arc, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> BoxFuture<'static, Option> { Box::pin(SessionTask::run( @@ -380,6 +380,11 @@ impl Session { )); let ctx = Arc::clone(&turn_context); let task_for_run = Arc::clone(&task); + let task_input = if input.is_empty() { + Vec::new() + } else { + vec![TurnInput::UserInput(input)] + }; let task_cancellation_token = cancellation_token.child_token(); // Task-owned turn spans keep a core-owned span open for the // full task lifecycle after the submission dispatch span ends. @@ -405,7 +410,7 @@ impl Session { .run( Arc::clone(&session_ctx), ctx, - input, + task_input, task_cancellation_token.child_token(), ) .await; diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 50414df2787b..6c6a0e5b0920 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -2,13 +2,13 @@ use std::sync::Arc; use tokio_util::sync::CancellationToken; +use crate::session::TurnInput; use crate::session::turn::run_turn; use crate::session::turn_context::TurnContext; use crate::session_startup_prewarm::SessionStartupPrewarmResolution; use crate::state::TaskKind; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TurnStartedEvent; -use codex_protocol::user_input::UserInput; use tracing::Instrument; use tracing::trace_span; @@ -41,7 +41,7 @@ impl SessionTask for RegularTask { self: Arc, session: Arc, ctx: Arc, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> Option { let sess = session.clone_session(); @@ -51,6 +51,7 @@ impl SessionTask for RegularTask { // not wait on startup prewarm resolution. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: ctx.sub_id.clone(), + trace_id: ctx.trace_id.clone(), started_at: ctx.turn_timing_state.started_at_unix_secs().await, model_context_window: ctx.model_context_window(), collaboration_mode_kind: ctx.collaboration_mode.mode, diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index f89c7d062f41..ff7f38a7eaef 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -20,6 +20,7 @@ use crate::codex_delegate::run_codex_thread_one_shot; use crate::config::Constrained; use crate::review_format::format_review_findings_block; use crate::review_format::render_review_output_text; +use crate::session::TurnInput; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::state::TaskKind; @@ -59,7 +60,7 @@ impl SessionTask for ReviewTask { self: Arc, session: Arc, ctx: Arc, - input: Vec, + input: Vec, cancellation_token: CancellationToken, ) -> Option { session.session.services.session_telemetry.counter( @@ -68,11 +69,19 @@ impl SessionTask for ReviewTask { &[], ); + let mut user_input = Vec::new(); + for item in input { + match item { + TurnInput::UserInput(mut content) => user_input.append(&mut content), + TurnInput::ResponseInputItem(_) => {} + } + } + // Start sub-codex conversation and get the receiver for events. let output = match start_review_conversation( session.clone(), ctx.clone(), - input, + user_input, cancellation_token.clone(), ) .await diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 23f3882edaf5..396aecbeea89 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -7,7 +7,6 @@ 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_protocol::user_input::UserInput; use tokio_util::sync::CancellationToken; use tracing::error; use uuid::Uuid; @@ -17,6 +16,7 @@ use crate::exec::StdoutStream; use crate::exec::execute_exec_request; use crate::exec_env::create_env; use crate::sandboxing::ExecRequest; +use crate::session::TurnInput; use crate::session::turn_context::TurnContext; use crate::state::TaskKind; use crate::tools::format_exec_output_str; @@ -77,7 +77,7 @@ impl SessionTask for UserShellCommandTask { self: Arc, session: Arc, turn_context: Arc, - _input: Vec, + _input: Vec, cancellation_token: CancellationToken, ) -> Option { execute_user_shell_command( @@ -114,6 +114,7 @@ pub(crate) async fn execute_user_shell_command( // freshly reinjected context before the summary/replacement history is applied. let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index e5b4fcf6a23f..232f12ca7115 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -928,6 +928,27 @@ impl ThreadManagerState { .collect() } + /// List parent-child edges for currently loaded thread-spawn agents. + pub(crate) async fn list_live_thread_spawn_edges(&self) -> Vec<(ThreadId, ThreadId)> { + self.threads + .read() + .await + .iter() + .filter_map(|(thread_id, thread)| { + if thread.session_source.is_internal() { + return None; + } + match &thread.session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + .. + }) => Some((*parent_thread_id, *thread_id)), + _ => None, + } + }) + .collect() + } + /// Fetch a thread by ID or return ThreadNotFound. pub(crate) async fn get_thread(&self, thread_id: ThreadId) -> CodexResult> { let threads = self.threads.read().await; diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 255a8336b9bf..c79a6859d790 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -187,6 +187,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { RolloutItem::ResponseItem(assistant_msg("a1")), RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-2".to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1307,6 +1308,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-explicit".to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 764e66f0cec1..470b32beaea7 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use codex_tools::ConversationHistory; use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -53,7 +54,7 @@ impl ToolExecutor for ExtensionToolAdapter { &self, invocation: ToolInvocation, ) -> Result, FunctionCallError> { - self.0.handle(to_extension_call(&invocation)).await + self.0.handle(to_extension_call(&invocation).await).await } } @@ -86,12 +87,15 @@ impl CoreToolRuntime for ExtensionToolAdapter { } } -fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { +async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { + let conversation_history = + ConversationHistory::new(invocation.session.clone_history().await.into_raw_items()); ExtensionToolCall { turn_id: invocation.turn.sub_id.clone(), call_id: invocation.call_id.clone(), tool_name: invocation.tool_name.clone(), truncation_policy: invocation.turn.truncation_policy, + conversation_history, payload: invocation.payload.clone(), } } @@ -108,6 +112,8 @@ fn extension_tool_hook_input(arguments: &str) -> Value { mod tests { use std::sync::Arc; + use codex_protocol::models::ContentItem; + use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; use serde_json::json; use tokio::sync::Mutex; @@ -236,6 +242,17 @@ mod tests { let (session, turn) = crate::session::tests::make_session_and_context().await; let turn_id = turn.sub_id.clone(); let truncation_policy = turn.truncation_policy; + let history_item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "extension history".to_string(), + }], + phase: None, + }; + session + .record_into_history(std::slice::from_ref(&history_item), &turn) + .await; let invocation = ToolInvocation { session: session.into(), turn: turn.into(), @@ -261,6 +278,10 @@ mod tests { codex_tools::ToolName::plain("extension_echo") ); assert_eq!(captured_call.truncation_policy, truncation_policy); + assert_eq!( + captured_call.conversation_history.items(), + std::slice::from_ref(&history_item) + ); match captured_call.payload { ToolPayload::Function { arguments } => { assert_eq!(arguments, json!({ "message": "hello" }).to_string()); diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index a6f3cf4ced32..5f6261323d41 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -49,7 +49,16 @@ impl ToolExecutor for McpHandler { } fn supports_parallel_tool_calls(&self) -> bool { + // Correctly implemented MCP servers should tolerate parallel calls to + // tools that advertise themselves as read-only. self.tool_info.supports_parallel_tool_calls + || self + .tool_info + .tool + .annotations + .as_ref() + .and_then(|annotations| annotations.read_only_hint) + .unwrap_or(false) } async fn handle( @@ -443,6 +452,44 @@ mod tests { assert_eq!(mcp_hook_tool_input(" "), json!({})); } + #[test] + fn mcp_read_only_hint_supports_parallel_calls_without_server_opt_in() { + let mut read_only_info = tool_info("foo", "mcp__foo__", "read"); + read_only_info.tool.annotations = Some(rmcp::model::ToolAnnotations::new().read_only(true)); + + assert!( + McpHandler::new(read_only_info) + .expect("MCP tool spec should build") + .supports_parallel_tool_calls() + ); + } + + #[test] + fn mcp_parallel_calls_require_read_only_hint_or_server_opt_in() { + let missing_hint_info = tool_info("foo", "mcp__foo__", "unannotated"); + assert!( + !McpHandler::new(missing_hint_info) + .expect("MCP tool spec should build") + .supports_parallel_tool_calls() + ); + + let mut writable_info = tool_info("foo", "mcp__foo__", "write"); + writable_info.tool.annotations = Some(rmcp::model::ToolAnnotations::new().read_only(false)); + assert!( + !McpHandler::new(writable_info) + .expect("MCP tool spec should build") + .supports_parallel_tool_calls() + ); + + let mut server_opt_in_info = tool_info("foo", "mcp__foo__", "server_opt_in"); + server_opt_in_info.supports_parallel_tool_calls = true; + assert!( + McpHandler::new(server_opt_in_info) + .expect("MCP tool spec should build") + .supports_parallel_tool_calls() + ); + } + fn tool_info(server_name: &str, callable_namespace: &str, tool_name: &str) -> ToolInfo { ToolInfo { server_name: server_name.to_string(), diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 375faba248e9..9d685e53b6e5 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -11,6 +11,7 @@ use codex_extension_api::ResponsesApiTool; use codex_extension_api::ToolCall as ExtensionToolCall; use codex_extension_api::ToolExecutor; use codex_protocol::dynamic_tools::DynamicToolSpec; +use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; @@ -81,6 +82,7 @@ impl ToolExecutor for ExtensionEchoExecutor { Ok(Box::new(codex_tools::JsonToolOutput::new(json!({ "arguments": arguments, "callId": call.call_id, + "conversationHistory": call.conversation_history.items(), "ok": true, })))) } @@ -327,6 +329,17 @@ fn mcp_tool_info( async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow::Result<()> { let (mut session, turn) = make_session_and_context().await; session.services.extensions = extension_tool_test_registry(); + let history_item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "extension history".to_string(), + }], + phase: None, + }; + session + .record_into_history(std::slice::from_ref(&history_item), &turn) + .await; let router = ToolRouter::from_turn_context( &turn, @@ -384,6 +397,7 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow json!({ "arguments": { "message": "hello" }, "callId": "call-extension", + "conversationHistory": [history_item], "ok": true, }) ); diff --git a/codex-rs/core/src/windows_sandbox.rs b/codex-rs/core/src/windows_sandbox.rs index 2c87c885ad6a..1494ce262eef 100644 --- a/codex-rs/core/src/windows_sandbox.rs +++ b/codex-rs/core/src/windows_sandbox.rs @@ -1,7 +1,6 @@ use crate::config::Config; use crate::config::edit::ConfigEditsBuilder; use codex_config::config_toml::ConfigToml; -use codex_config::profile_toml::ConfigProfile; use codex_config::types::WindowsSandboxModeToml; use codex_features::Feature; use codex_features::Features; @@ -56,47 +55,20 @@ pub fn windows_sandbox_level_from_features(features: &Features) -> WindowsSandbo WindowsSandboxLevel::from_features(features) } -pub fn resolve_windows_sandbox_mode( - cfg: &ConfigToml, - profile: &ConfigProfile, -) -> Option { - if let Some(mode) = legacy_windows_sandbox_mode(profile.features.as_ref()) { - return Some(mode); - } - if legacy_windows_sandbox_keys_present(profile.features.as_ref()) { - return None; - } - - profile - .windows +pub fn resolve_windows_sandbox_mode(cfg: &ConfigToml) -> Option { + cfg.windows .as_ref() .and_then(|windows| windows.sandbox) - .or_else(|| cfg.windows.as_ref().and_then(|windows| windows.sandbox)) .or_else(|| legacy_windows_sandbox_mode(cfg.features.as_ref())) } -pub fn resolve_windows_sandbox_private_desktop(cfg: &ConfigToml, profile: &ConfigProfile) -> bool { - profile - .windows +pub fn resolve_windows_sandbox_private_desktop(cfg: &ConfigToml) -> bool { + cfg.windows .as_ref() .and_then(|windows| windows.sandbox_private_desktop) - .or_else(|| { - cfg.windows - .as_ref() - .and_then(|windows| windows.sandbox_private_desktop) - }) .unwrap_or(true) } -fn legacy_windows_sandbox_keys_present(features: Option<&FeaturesToml>) -> bool { - let Some(entries) = features.map(FeaturesToml::entries) else { - return false; - }; - entries.contains_key(Feature::WindowsSandboxElevated.key()) - || entries.contains_key(Feature::WindowsSandbox.key()) - || entries.contains_key("enable_experimental_windows_sandbox") -} - pub fn legacy_windows_sandbox_mode( features: Option<&FeaturesToml>, ) -> Option { @@ -280,7 +252,6 @@ pub struct WindowsSandboxSetupRequest { pub command_cwd: PathBuf, pub env_map: HashMap, pub codex_home: PathBuf, - pub active_profile: Option, } pub async fn run_windows_sandbox_setup(request: WindowsSandboxSetupRequest) -> anyhow::Result<()> { @@ -319,7 +290,6 @@ async fn run_windows_sandbox_setup_and_persist( let command_cwd = request.command_cwd; let env_map = request.env_map; let codex_home = request.codex_home; - let active_profile = request.active_profile; let setup_codex_home = codex_home.clone(); let setup_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> { @@ -353,7 +323,6 @@ async fn run_windows_sandbox_setup_and_persist( setup_result?; ConfigEditsBuilder::new(codex_home.as_path()) - .with_profile(active_profile.as_deref()) .set_windows_sandbox_mode(windows_sandbox_setup_mode_tag(mode)) .clear_legacy_windows_sandbox_keys() .apply() diff --git a/codex-rs/core/src/windows_sandbox_tests.rs b/codex-rs/core/src/windows_sandbox_tests.rs index 27612c640a5c..5a66c8c96a29 100644 --- a/codex-rs/core/src/windows_sandbox_tests.rs +++ b/codex-rs/core/src/windows_sandbox_tests.rs @@ -78,29 +78,6 @@ fn legacy_mode_supports_alias_key() { ); } -#[test] -fn resolve_windows_sandbox_mode_prefers_profile_windows() { - let cfg = ConfigToml { - windows: Some(WindowsToml { - sandbox: Some(WindowsSandboxModeToml::Unelevated), - ..Default::default() - }), - ..Default::default() - }; - let profile = ConfigProfile { - windows: Some(WindowsToml { - sandbox: Some(WindowsSandboxModeToml::Elevated), - ..Default::default() - }), - ..Default::default() - }; - - assert_eq!( - resolve_windows_sandbox_mode(&cfg, &profile), - Some(WindowsSandboxModeToml::Elevated) - ); -} - #[test] fn resolve_windows_sandbox_mode_falls_back_to_legacy_keys() { let mut entries = BTreeMap::new(); @@ -114,61 +91,15 @@ fn resolve_windows_sandbox_mode_falls_back_to_legacy_keys() { }; assert_eq!( - resolve_windows_sandbox_mode(&cfg, &ConfigProfile::default()), + resolve_windows_sandbox_mode(&cfg), Some(WindowsSandboxModeToml::Unelevated) ); } -#[test] -fn resolve_windows_sandbox_mode_profile_legacy_false_blocks_top_level_legacy_true() { - let mut profile_entries = BTreeMap::new(); - profile_entries.insert( - "experimental_windows_sandbox".to_string(), - /*value*/ false, - ); - let profile = ConfigProfile { - features: Some(FeaturesToml::from(profile_entries)), - ..Default::default() - }; - - let mut cfg_entries = BTreeMap::new(); - cfg_entries.insert( - "experimental_windows_sandbox".to_string(), - /*value*/ true, - ); - let cfg = ConfigToml { - features: Some(FeaturesToml::from(cfg_entries)), - ..Default::default() - }; - - assert_eq!(resolve_windows_sandbox_mode(&cfg, &profile), None); -} - -#[test] -fn resolve_windows_sandbox_private_desktop_prefers_profile_windows() { - let cfg = ConfigToml { - windows: Some(WindowsToml { - sandbox: Some(WindowsSandboxModeToml::Unelevated), - sandbox_private_desktop: Some(false), - }), - ..Default::default() - }; - let profile = ConfigProfile { - windows: Some(WindowsToml { - sandbox: Some(WindowsSandboxModeToml::Elevated), - sandbox_private_desktop: Some(true), - }), - ..Default::default() - }; - - assert!(resolve_windows_sandbox_private_desktop(&cfg, &profile)); -} - #[test] fn resolve_windows_sandbox_private_desktop_defaults_to_true() { assert!(resolve_windows_sandbox_private_desktop( - &ConfigToml::default(), - &ConfigProfile::default() + &ConfigToml::default() )); } @@ -182,8 +113,5 @@ fn resolve_windows_sandbox_private_desktop_respects_explicit_cfg_value() { ..Default::default() }; - assert!(!resolve_windows_sandbox_private_desktop( - &cfg, - &ConfigProfile::default() - )); + assert!(!resolve_windows_sandbox_private_desktop(&cfg)); } diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index d868abbfee32..b614c09d3a52 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -816,6 +816,118 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compact_v2_retries_failures_with_stream_retry_budget() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = TestCodexHarness::with_builder( + test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + let _ = config.features.enable(Feature::RemoteCompactionV2); + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(2); + }), + ) + .await?; + let codex = harness.test().codex.clone(); + + let responses_mock = responses::mount_response_sequence( + harness.server(), + vec![ + responses::sse_response(responses::sse(vec![ + responses::ev_assistant_message("m1", "FIRST_REMOTE_REPLY"), + responses::ev_completed("resp-1"), + ])), + ResponseTemplate::new(500).set_body_string("first compact open failed"), + responses::sse_response(responses::sse(vec![serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": "FAILED_COMPACT_SUMMARY", + } + })])), + responses::sse_response(responses::sse(vec![ + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": "RETRIED_COMPACT_SUMMARY", + } + }), + responses::ev_completed("resp-compact-retry"), + ])), + responses::sse_response(responses::sse(vec![ + responses::ev_assistant_message("m2", "AFTER_COMPACT_REPLY"), + responses::ev_completed("resp-2"), + ])), + ], + ) + .await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "hello remote compact".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + thread_settings: Default::default(), + }) + .await?; + wait_for_turn_complete(&codex).await; + + codex.submit(Op::Compact).await?; + wait_for_turn_complete(&codex).await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "after compact".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + thread_settings: Default::default(), + }) + .await?; + wait_for_turn_complete(&codex).await; + + let response_requests = responses_mock.requests(); + assert_eq!( + 5, + response_requests.len(), + "expected initial turn, failed open, failed stream, compact retry, and follow-up turn" + ); + + for compact_request in &response_requests[1..=3] { + assert_eq!("/v1/responses", compact_request.path()); + assert!( + compact_request + .body_json() + .to_string() + .contains("\"type\":\"compaction_trigger\""), + "expected v2 compaction request to include the compaction_trigger item" + ); + } + + let follow_up_request = response_requests.last().expect("follow-up request missing"); + let follow_up_body = follow_up_request.body_json().to_string(); + assert!( + follow_up_body.contains("RETRIED_COMPACT_SUMMARY"), + "expected follow-up request to include the retried compaction payload" + ); + assert!( + !follow_up_body.contains("FAILED_COMPACT_SUMMARY"), + "expected failed compaction attempt output to be discarded" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_compact_v2_accepts_additional_output_items_before_compaction() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/live_cli.rs b/codex-rs/core/tests/suite/live_cli.rs index 5e2c0415ea7f..6273cd15e44b 100644 --- a/codex-rs/core/tests/suite/live_cli.rs +++ b/codex-rs/core/tests/suite/live_cli.rs @@ -2,7 +2,8 @@ //! Optional smoke tests that hit the real OpenAI /v1/responses endpoint. They are `#[ignore]` by //! default so CI stays deterministic and free. Developers can run them locally with -//! `cargo test --test live_cli -- --ignored` provided they set a valid `OPENAI_API_KEY`. +//! `just test -p codex-core --test all --run-ignored only live_cli` provided they set a valid +//! `OPENAI_API_KEY`. use assert_cmd::prelude::*; use predicates::prelude::*; diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index d2bdcd1d3203..51242ede2afd 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -50,6 +50,7 @@ fn resume_history( history: vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 94222deab8b2..864fb1e07a05 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -101,10 +101,28 @@ fn read_only_user_turn_with_model( fixture: &TestCodex, text: impl Into, model: String, +) -> Op { + user_turn_with_permission_profile(fixture, text, model, PermissionProfile::read_only()) +} + +fn auto_approved_user_turn(fixture: &TestCodex, text: impl Into) -> Op { + user_turn_with_permission_profile( + fixture, + text, + fixture.session_configured.model.clone(), + PermissionProfile::Disabled, + ) +} + +fn user_turn_with_permission_profile( + fixture: &TestCodex, + text: impl Into, + model: String, + permission_profile: PermissionProfile, ) -> Op { let cwd = fixture.cwd.path().to_path_buf(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::read_only(), cwd.as_path()); + turn_permission_fields(permission_profile, cwd.as_path()); Op::UserInput { items: vec![UserInput::Text { text: text.into(), @@ -840,7 +858,10 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow:: .await?; fixture .codex - .submit(read_only_user_turn( + // Keep this baseline on the mutable sync tool so read-only hints do not + // make the call parallel-safe. Bypass read-only turn permissions so + // approval behavior does not block the scheduling assertion. + .submit(auto_approved_user_turn( &fixture, "call the rmcp sync tool twice", )) @@ -899,6 +920,102 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow:: Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stdio_mcp_read_only_tool_calls_run_concurrently_without_server_opt_in() +-> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + + let first_call_id = "sync-read-only-1"; + let second_call_id = "sync-read-only-2"; + let server_name = "rmcp"; + let namespace = format!("mcp__{server_name}__"); + // The stdio MCP test server holds each sync call at this barrier until both + // calls arrive. A serial scheduler times out inside the server instead of + // returning the structured `{ "result": "ok" }` result asserted below. + let args = json!({ + "sleep_after_ms": 100, + "barrier": { + "id": "stdio-mcp-read-only-tool-calls", + "participants": 2, + "timeout_ms": 1_000 + } + }) + .to_string(); + + mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + first_call_id, + &namespace, + "sync_readonly", + &args, + ), + responses::ev_function_call_with_namespace( + second_call_id, + &namespace, + "sync_readonly", + &args, + ), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let final_mock = mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "rmcp sync tools completed successfully."), + responses::ev_completed("resp-2"), + ]), + ) + .await; + + let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; + + let fixture = test_codex() + .with_config(move |config| { + insert_mcp_server( + config, + server_name, + stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()), + TestMcpServerOptions { + environment_id: remote_aware_environment_id(), + tool_timeout_sec: Some(Duration::from_secs(2)), + ..Default::default() + }, + ); + }) + .build_with_remote_env(&server) + .await?; + fixture + .codex + .submit(read_only_user_turn( + &fixture, + "call the rmcp sync_readonly tool twice", + )) + .await?; + + wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request = final_mock.single_request(); + for call_id in [first_call_id, second_call_id] { + let output_text = request + .function_call_output_text(call_id) + .expect("function_call_output present for rmcp sync call"); + let wrapped_payload = split_wall_time_wrapped_output(&output_text); + let output_json: Value = serde_json::from_str(wrapped_payload) + .expect("wrapped MCP output should preserve structured JSON"); + assert_eq!(output_json, json!({ "result": "ok" })); + } + + server.verify().await; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -957,7 +1074,10 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res .await?; fixture .codex - .submit(read_only_user_turn( + // Exercise the server opt-in with the mutable sync tool rather than the + // read-only sync_readonly tool. Bypass read-only turn permissions so + // approval behavior does not block the scheduling assertion. + .submit(auto_approved_user_turn( &fixture, "call the rmcp sync tool twice", )) diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index d16c58a10e0a..d3c07a115394 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -151,6 +151,21 @@ print(json.dumps({{"hookSpecificOutput": {{"hookEventName": "SubagentStart", "ad start_log_path = start_log_path.display(), ); + let user_prompt_submit_script_path = home.join("user_prompt_submit_hook.py"); + let user_prompt_submit_log_path = home.join("user_prompt_submit_hook_log.jsonl"); + let user_prompt_submit_script = format!( + r#"import json +from pathlib import Path +import sys + +log_path = Path(r"{user_prompt_submit_log_path}") +payload = json.load(sys.stdin) +with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +"#, + user_prompt_submit_log_path = user_prompt_submit_log_path.display(), + ); + let subagent_stop_script_path = home.join("subagent_stop_hook.py"); let subagent_stop_log_path = home.join("subagent_stop_hook_log.jsonl"); let prompts_json = serde_json::to_string(stop_prompts)?; @@ -212,6 +227,12 @@ print(json.dumps({{"systemMessage": "root stop complete"}})) "command": format!("python3 {}", start_script_path.display()), }] }], + "UserPromptSubmit": [{ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", user_prompt_submit_script_path.display()), + }] + }], "SubagentStop": [{ "matcher": subagent_stop_matcher, "hooks": [{ @@ -230,6 +251,7 @@ print(json.dumps({{"systemMessage": "root stop complete"}})) fs::write(&session_start_script_path, session_start_script)?; fs::write(&start_script_path, start_script)?; + fs::write(&user_prompt_submit_script_path, user_prompt_submit_script)?; fs::write(&subagent_stop_script_path, subagent_stop_script)?; fs::write(&stop_script_path, stop_script)?; fs::write(home.join("hooks.json"), hooks.to_string())?; @@ -504,7 +526,9 @@ async fn subagent_start_replaces_session_start_and_injects_context() -> Result<( let test = test_codex() .with_pre_build_hook(|home| { - if let Err(error) = write_subagent_lifecycle_hooks(home, &[], "worker") { + if let Err(error) = + write_subagent_lifecycle_hooks(home, /*stop_prompts*/ &[], "worker") + { panic!("failed to write subagent hook fixture: {error}"); } }) @@ -535,6 +559,29 @@ async fn subagent_start_replaces_session_start_and_injects_context() -> Result<( Some(spawned_id.as_str()) ); + let user_prompt_submit_inputs = wait_for_hook_log( + test.codex_home_path(), + "user_prompt_submit_hook_log.jsonl", + /*expected_len*/ 2, + ) + .await?; + let parent_prompt_input = user_prompt_submit_inputs + .iter() + .find(|input| input["prompt"].as_str() == Some(TURN_1_PROMPT)) + .expect("parent prompt submit hook input should be logged"); + assert_eq!(parent_prompt_input.get("agent_id"), None); + assert_eq!(parent_prompt_input.get("agent_type"), None); + + let child_prompt_input = user_prompt_submit_inputs + .iter() + .find(|input| input["prompt"].as_str() == Some(CHILD_PROMPT)) + .expect("child prompt submit hook input should be logged"); + assert_eq!( + child_prompt_input["agent_id"].as_str(), + Some(spawned_id.as_str()) + ); + assert_eq!(child_prompt_input["agent_type"].as_str(), Some("worker")); + let session_start_inputs = wait_for_hook_log( test.codex_home_path(), "session_start_hook_log.jsonl", @@ -626,9 +673,11 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<() let test = test_codex() .with_pre_build_hook(|home| { - if let Err(error) = - write_subagent_lifecycle_hooks(home, &[SUBAGENT_STOP_CONTINUATION], "") - { + if let Err(error) = write_subagent_lifecycle_hooks( + home, + /*stop_prompts*/ &[SUBAGENT_STOP_CONTINUATION], + "", + ) { panic!("failed to write subagent hook fixture: {error}"); } }) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 8ca5ba8a9a79..52590b56cca4 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -134,7 +134,6 @@ pub use exec_events::TurnStartedEvent; pub use exec_events::Usage; pub use exec_events::WebSearchItem; use serde_json::Value; -use std::collections::HashMap; use std::io::IsTerminal; use std::io::Read; use std::path::Path; @@ -375,11 +374,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result let run_cloud_requirements = cloud_requirements.clone(); let model_provider = if oss { - let resolved = resolve_oss_provider( - oss_provider.as_deref(), - &config_toml, - /*config_profile*/ None, - ); + let resolved = resolve_oss_provider(oss_provider.as_deref(), &config_toml); if let Some(provider) = resolved { Some(provider) @@ -418,7 +413,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result workspace_roots: None, model_provider: model_provider.clone(), service_tier: None, - config_profile: None, codex_self_exe: arg0_paths.codex_self_exe.clone(), codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), @@ -972,7 +966,7 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox: sandbox.flatten(), permissions, - config: config_request_overrides_from_config(config), + config: None, ephemeral: Some(config.ephemeral), thread_source: Some(ThreadSource::User), ..ThreadStartParams::default() @@ -1003,7 +997,7 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox: sandbox.flatten(), permissions, - config: config_request_overrides_from_config(config), + config: None, ..ThreadResumeParams::default() } } @@ -1044,13 +1038,6 @@ fn sandbox_mode_from_permission_profile( } } -fn config_request_overrides_from_config(config: &Config) -> Option> { - config - .active_profile - .as_ref() - .map(|profile| HashMap::from([("profile".to_string(), Value::String(profile.clone()))])) -} - fn approvals_reviewer_override_from_config( config: &Config, ) -> Option { diff --git a/codex-rs/execpolicy/Cargo.toml b/codex-rs/execpolicy/Cargo.toml index b22226a79e4b..b87af084d7d1 100644 --- a/codex-rs/execpolicy/Cargo.toml +++ b/codex-rs/execpolicy/Cargo.toml @@ -21,6 +21,7 @@ workspace = true anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-utils-absolute-path = { workspace = true } +codex-utils-file-lock = { workspace = true } multimap = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/execpolicy/src/amend.rs b/codex-rs/execpolicy/src/amend.rs index e25fd1bd9144..1ef36b1ff5d8 100644 --- a/codex-rs/execpolicy/src/amend.rs +++ b/codex-rs/execpolicy/src/amend.rs @@ -6,6 +6,10 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; +use codex_utils_file_lock::FileLockOutcome; +use codex_utils_file_lock::acquire_sibling_lock_dir; +use codex_utils_file_lock::lock_exclusive_optional; + use crate::decision::Decision; use crate::rule::NetworkRuleProtocol; use crate::rule::normalize_network_rule_host; @@ -154,10 +158,21 @@ fn append_locked_line(policy_path: &Path, line: &str) -> Result<(), AmendError> path: policy_path.to_path_buf(), source, })?; - file.lock().map_err(|source| AmendError::LockPolicyFile { - path: policy_path.to_path_buf(), - source, - })?; + let _lock_dir_guard = + match lock_exclusive_optional(&file).map_err(|source| AmendError::LockPolicyFile { + path: policy_path.to_path_buf(), + source, + })? { + FileLockOutcome::Acquired => None, + FileLockOutcome::Unsupported => { + Some(acquire_sibling_lock_dir(policy_path).map_err(|source| { + AmendError::LockPolicyFile { + path: policy_path.to_path_buf(), + source, + } + })?) + } + }; file.seek(SeekFrom::Start(0)) .map_err(|source| AmendError::SeekPolicyFile { diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 06cbc6f88989..a7c5c87e5207 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -10,6 +10,7 @@ pub use capabilities::NoopExtensionEventSink; pub use capabilities::NoopResponseItemInjector; pub use capabilities::ResponseItemInjectionFuture; pub use capabilities::ResponseItemInjector; +pub use codex_tools::ConversationHistory; pub use codex_tools::FunctionCallError; pub use codex_tools::JsonToolOutput; pub use codex_tools::ResponsesApiTool; diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index 28e55064fd67..cdeacbebe6cd 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -625,6 +625,7 @@ fn tool_call(tool_name: &str, call_id: &str, arguments: serde_json::Value) -> To call_id: call_id.to_string(), tool_name: codex_extension_api::ToolName::plain(tool_name), truncation_policy: TruncationPolicy::Bytes(1024), + conversation_history: codex_extension_api::ConversationHistory::default(), payload: ToolPayload::Function { arguments: arguments.to_string(), }, diff --git a/codex-rs/ext/memories/src/tests.rs b/codex-rs/ext/memories/src/tests.rs index e88d7d8db912..c2e90e6520c4 100644 --- a/codex-rs/ext/memories/src/tests.rs +++ b/codex-rs/ext/memories/src/tests.rs @@ -139,6 +139,7 @@ async fn read_tool_reads_memory_file() { call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::READ_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), + conversation_history: codex_extension_api::ConversationHistory::default(), payload: payload.clone(), }) .await @@ -183,6 +184,7 @@ async fn search_tool_accepts_multiple_queries() { call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), + conversation_history: codex_extension_api::ConversationHistory::default(), payload: payload.clone(), }) .await @@ -253,6 +255,7 @@ async fn search_tool_accepts_windowed_all_match_mode() { call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), + conversation_history: codex_extension_api::ConversationHistory::default(), payload: payload.clone(), }) .await @@ -303,6 +306,7 @@ async fn search_tool_rejects_legacy_single_query() { call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), + conversation_history: codex_extension_api::ConversationHistory::default(), payload, }) .await; diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index 3682a4f7f816..01220c07c178 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -67,6 +67,7 @@ fn rollout_items_from_messages(messages: &[ConversationMessage]) -> Vec) -> Option { if chunks.is_empty() { None diff --git a/codex-rs/hooks/src/events/compact.rs b/codex-rs/hooks/src/events/compact.rs index 469fdda232f7..cb3080219a56 100644 --- a/codex-rs/hooks/src/events/compact.rs +++ b/codex-rs/hooks/src/events/compact.rs @@ -17,11 +17,13 @@ use crate::engine::dispatcher; use crate::engine::output_parser; use crate::schema::PostCompactCommandInput; use crate::schema::PreCompactCommandInput; +use crate::schema::SubagentCommandInputFields; #[derive(Debug, Clone)] pub struct PreCompactRequest { pub session_id: ThreadId, pub turn_id: String, + pub subagent: Option, pub cwd: AbsolutePathBuf, pub transcript_path: Option, pub model: String, @@ -32,6 +34,7 @@ pub struct PreCompactRequest { pub struct PostCompactRequest { pub session_id: ThreadId, pub turn_id: String, + pub subagent: Option, pub cwd: AbsolutePathBuf, pub transcript_path: Option, pub model: String, @@ -120,9 +123,12 @@ pub(crate) async fn run_pre( } fn pre_command_input_json(request: &PreCompactRequest) -> Result { + let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); serde_json::to_string(&PreCompactCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), + agent_id: subagent.agent_id, + agent_type: subagent.agent_type, transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()), cwd: request.cwd.display().to_string(), hook_event_name: "PreCompact".to_string(), @@ -199,9 +205,12 @@ pub(crate) async fn run_post( } fn post_command_input_json(request: &PostCompactRequest) -> Result { + let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); serde_json::to_string(&PostCompactCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), + agent_id: subagent.agent_id, + agent_type: subagent.agent_type, transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()), cwd: request.cwd.display().to_string(), hook_event_name: "PostCompact".to_string(), @@ -563,6 +572,7 @@ mod tests { session_id: ThreadId::from_string("00000000-0000-4000-8000-000000000001") .expect("valid thread id"), turn_id: "turn-1".to_string(), + subagent: None, cwd: test_path_buf("/tmp").abs(), transcript_path: None, model: "gpt-test".to_string(), @@ -575,6 +585,7 @@ mod tests { session_id: ThreadId::from_string("00000000-0000-4000-8000-000000000002") .expect("valid thread id"), turn_id: "turn-1".to_string(), + subagent: None, cwd: test_path_buf("/tmp").abs(), transcript_path: None, model: "gpt-test".to_string(), diff --git a/codex-rs/hooks/src/events/permission_request.rs b/codex-rs/hooks/src/events/permission_request.rs index 79d06082369a..db7970f02dab 100644 --- a/codex-rs/hooks/src/events/permission_request.rs +++ b/codex-rs/hooks/src/events/permission_request.rs @@ -22,6 +22,7 @@ use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; use crate::schema::PermissionRequestCommandInput; +use crate::schema::SubagentCommandInputFields; use codex_protocol::ThreadId; use codex_protocol::protocol::HookCompletedEvent; use codex_protocol::protocol::HookEventName; @@ -35,6 +36,7 @@ use serde_json::Value; pub struct PermissionRequestRequest { pub session_id: ThreadId, pub turn_id: String, + pub subagent: Option, pub cwd: PathBuf, pub transcript_path: Option, pub model: String, @@ -168,9 +170,12 @@ fn resolve_permission_request_decision<'a>( } fn build_command_input(request: &PermissionRequestRequest) -> PermissionRequestCommandInput { + let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); PermissionRequestCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), + agent_id: subagent.agent_id, + agent_type: subagent.agent_type, transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()), cwd: request.cwd.display().to_string(), hook_event_name: "PermissionRequest".to_string(), diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index 801c5f09e9c2..f096de011098 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -17,11 +17,13 @@ use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; use crate::schema::PostToolUseCommandInput; +use crate::schema::SubagentCommandInputFields; #[derive(Debug, Clone)] pub struct PostToolUseRequest { pub session_id: ThreadId, pub turn_id: String, + pub subagent: Option, pub cwd: AbsolutePathBuf, pub transcript_path: Option, pub model: String, @@ -148,9 +150,12 @@ pub(crate) async fn run( /// events across processes. Shell-like tools pass `{ "command": ... }` as /// `tool_input`; MCP tools pass their resolved JSON arguments. fn command_input_json(request: &PostToolUseRequest) -> Result { + let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); serde_json::to_string(&PostToolUseCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), + agent_id: subagent.agent_id, + agent_type: subagent.agent_type, transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()), cwd: request.cwd.display().to_string(), hook_event_name: "PostToolUse".to_string(), @@ -571,6 +576,7 @@ mod tests { super::PostToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), + subagent: None, cwd: test_path_buf("/tmp").abs(), transcript_path: None, model: "gpt-test".to_string(), diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index b21daf063b86..b3579aba824e 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -17,11 +17,13 @@ use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; use crate::schema::PreToolUseCommandInput; +use crate::schema::SubagentCommandInputFields; #[derive(Debug, Clone)] pub struct PreToolUseRequest { pub session_id: ThreadId, pub turn_id: String, + pub subagent: Option, pub cwd: AbsolutePathBuf, pub transcript_path: Option, pub model: String, @@ -166,9 +168,12 @@ fn latest_updated_input( /// stable. Shell-like tools pass `{ "command": ... }` as `tool_input`; MCP /// tools pass their resolved JSON arguments. fn command_input_json(request: &PreToolUseRequest) -> Result { + let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); serde_json::to_string(&PreToolUseCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), + agent_id: subagent.agent_id, + agent_type: subagent.agent_type, transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()), cwd: request.cwd.display().to_string(), hook_event_name: "PreToolUse".to_string(), @@ -763,6 +768,7 @@ mod tests { super::PreToolUseRequest { session_id: ThreadId::new(), turn_id: "turn-1".to_string(), + subagent: None, cwd: test_path_buf("/tmp").abs(), transcript_path: None, model: "gpt-test".to_string(), diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index eb152a1f48e8..2934bd352397 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -16,12 +16,14 @@ use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; use crate::schema::NullableString; +use crate::schema::SubagentCommandInputFields; use crate::schema::UserPromptSubmitCommandInput; #[derive(Debug, Clone)] pub struct UserPromptSubmitRequest { pub session_id: ThreadId, pub turn_id: String, + pub subagent: Option, pub cwd: AbsolutePathBuf, pub transcript_path: Option, pub model: String, @@ -77,9 +79,12 @@ pub(crate) async fn run( }; } + let subagent = SubagentCommandInputFields::from(request.subagent.as_ref()); let input_json = match serde_json::to_string(&UserPromptSubmitCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), + agent_id: subagent.agent_id, + agent_type: subagent.agent_type, transcript_path: NullableString::from_path(request.transcript_path.clone()), cwd: request.cwd.display().to_string(), hook_event_name: "UserPromptSubmit".to_string(), diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 70ee3b454053..11300802d86e 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -14,6 +14,7 @@ pub use config_rules::hook_states_from_stack; pub use declarations::PluginHookDeclaration; pub use declarations::plugin_hook_declarations; pub use engine::HookListEntry; +pub use events::common::SubagentHookContext; /// Hook event names as they appear in hooks JSON and config files. pub const HOOK_EVENT_NAMES: [&str; 10] = [ "PreToolUse", diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index bbed57d36d22..3f17986dec27 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -12,6 +12,8 @@ use serde_json::Value; use std::path::Path; use std::path::PathBuf; +use crate::events::common::SubagentHookContext; + const GENERATED_DIR: &str = "generated"; const POST_TOOL_USE_INPUT_FIXTURE: &str = "post-tool-use.command.input.schema.json"; const POST_TOOL_USE_OUTPUT_FIXTURE: &str = "post-tool-use.command.output.schema.json"; @@ -61,6 +63,24 @@ impl JsonSchema for NullableString { } } +#[derive(Debug, Clone, Default)] +pub(crate) struct SubagentCommandInputFields { + pub agent_id: Option, + pub agent_type: Option, +} + +impl From> for SubagentCommandInputFields { + fn from(value: Option<&SubagentHookContext>) -> Self { + match value { + Some(context) => Self { + agent_id: Some(context.agent_id.clone()), + agent_type: Some(context.agent_type.clone()), + }, + None => Self::default(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -251,6 +271,10 @@ pub(crate) struct PreToolUseCommandInput { pub session_id: String, /// Codex extension: expose the active turn id to internal turn-scoped hooks. pub turn_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, pub transcript_path: NullableString, pub cwd: String, #[schemars(schema_with = "pre_tool_use_hook_event_name_schema")] @@ -270,6 +294,10 @@ pub(crate) struct PermissionRequestCommandInput { pub session_id: String, /// Codex extension: expose the active turn id to internal turn-scoped hooks. pub turn_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, pub transcript_path: NullableString, pub cwd: String, #[schemars(schema_with = "permission_request_hook_event_name_schema")] @@ -288,6 +316,10 @@ pub(crate) struct PostToolUseCommandInput { pub session_id: String, /// Codex extension: expose the active turn id to internal turn-scoped hooks. pub turn_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, pub transcript_path: NullableString, pub cwd: String, #[schemars(schema_with = "post_tool_use_hook_event_name_schema")] @@ -308,6 +340,10 @@ pub(crate) struct PreCompactCommandInput { pub session_id: String, /// Codex extension: expose the active turn id to internal turn-scoped hooks. pub turn_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, pub transcript_path: NullableString, pub cwd: String, #[schemars(schema_with = "pre_compact_hook_event_name_schema")] @@ -324,6 +360,10 @@ pub(crate) struct PostCompactCommandInput { pub session_id: String, /// Codex extension: expose the active turn id to internal turn-scoped hooks. pub turn_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, pub transcript_path: NullableString, pub cwd: String, #[schemars(schema_with = "post_compact_hook_event_name_schema")] @@ -486,6 +526,10 @@ pub(crate) struct UserPromptSubmitCommandInput { pub session_id: String, /// Codex extension: expose the active turn id to internal turn-scoped hooks. pub turn_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, pub transcript_path: NullableString, pub cwd: String, #[schemars(schema_with = "user_prompt_submit_hook_event_name_schema")] @@ -761,6 +805,7 @@ fn default_continue() -> bool { #[cfg(test)] mod tests { + use super::NullableString; use super::PERMISSION_REQUEST_INPUT_FIXTURE; use super::PERMISSION_REQUEST_OUTPUT_FIXTURE; use super::POST_COMPACT_INPUT_FIXTURE; @@ -785,6 +830,7 @@ mod tests { use super::SUBAGENT_STOP_INPUT_FIXTURE; use super::SUBAGENT_STOP_OUTPUT_FIXTURE; use super::StopCommandInput; + use super::SubagentCommandInputFields; use super::SubagentStartCommandInput; use super::SubagentStopCommandInput; use super::USER_PROMPT_SUBMIT_INPUT_FIXTURE; @@ -792,8 +838,10 @@ mod tests { use super::UserPromptSubmitCommandInput; use super::schema_json; use super::write_schema_fixtures; + use crate::events::common::SubagentHookContext; use pretty_assertions::assert_eq; use serde_json::Value; + use serde_json::json; use tempfile::TempDir; fn expected_fixture(name: &str) -> &'static str { @@ -968,4 +1016,87 @@ mod tests { ); } } + + #[test] + fn subagent_context_fields_are_optional_for_hooks_that_run_inside_subagents() { + let schemas = [ + schema_json::().expect("serialize pre tool use input schema"), + schema_json::() + .expect("serialize permission request input schema"), + schema_json::().expect("serialize post tool use input schema"), + schema_json::().expect("serialize pre compact input schema"), + schema_json::().expect("serialize post compact input schema"), + schema_json::() + .expect("serialize user prompt submit input schema"), + ]; + + for schema in schemas { + let schema: Value = serde_json::from_slice(&schema).expect("parse hook input schema"); + assert_eq!(schema["properties"]["agent_id"]["type"], "string"); + assert_eq!(schema["properties"]["agent_type"]["type"], "string"); + let required = schema["required"] + .as_array() + .expect("schema required fields"); + assert!(!required.contains(&Value::String("agent_id".to_string()))); + assert!(!required.contains(&Value::String("agent_type".to_string()))); + } + } + + #[test] + fn subagent_context_fields_serialize_flat_and_omit_when_absent() { + let subagent = SubagentCommandInputFields::from(Some(&SubagentHookContext { + agent_id: "agent-1".to_string(), + agent_type: "worker".to_string(), + })); + let input = PreToolUseCommandInput { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + agent_id: subagent.agent_id, + agent_type: subagent.agent_type, + transcript_path: NullableString::from_path(/*path*/ None), + cwd: "/tmp".to_string(), + hook_event_name: "PreToolUse".to_string(), + model: "gpt-test".to_string(), + permission_mode: "default".to_string(), + tool_name: "Bash".to_string(), + tool_input: json!({ "command": "echo hello" }), + tool_use_id: "tool-1".to_string(), + }; + + assert_eq!( + serde_json::to_value(input).expect("serialize subagent hook input"), + json!({ + "session_id": "session-1", + "turn_id": "turn-1", + "agent_id": "agent-1", + "agent_type": "worker", + "transcript_path": null, + "cwd": "/tmp", + "hook_event_name": "PreToolUse", + "model": "gpt-test", + "permission_mode": "default", + "tool_name": "Bash", + "tool_input": { "command": "echo hello" }, + "tool_use_id": "tool-1", + }) + ); + + let root_input = PreToolUseCommandInput { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + agent_id: None, + agent_type: None, + transcript_path: NullableString::from_path(/*path*/ None), + cwd: "/tmp".to_string(), + hook_event_name: "PreToolUse".to_string(), + model: "gpt-test".to_string(), + permission_mode: "default".to_string(), + tool_name: "Bash".to_string(), + tool_input: json!({ "command": "echo hello" }), + tool_use_id: "tool-1".to_string(), + }; + let root_input = serde_json::to_value(root_input).expect("serialize root hook input"); + assert_eq!(root_input.get("agent_id"), None); + assert_eq!(root_input.get("agent_type"), None); + } } diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index 4fc65c749922..07c679647088 100644 --- a/codex-rs/linux-sandbox/README.md +++ b/codex-rs/linux-sandbox/README.md @@ -94,4 +94,4 @@ commands that would enter the bubblewrap path. you can skip this in restrictive container environments with `--no-proc`. **Notes** -- The CLI surface still uses legacy names like `codex debug landlock`. +- The CLI surface is `codex sandbox`; the host OS selects the sandbox backend. diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index a2e4e8e0d866..d94c48165dce 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -15,6 +15,7 @@ use std::sync::RwLock; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; use tokio::sync::Semaphore; +use tokio::sync::watch; use codex_agent_identity::decode_agent_identity_jwt; use codex_agent_identity::fetch_agent_identity_jwks; @@ -1252,6 +1253,7 @@ impl UnauthorizedRecovery { pub struct AuthManager { codex_home: PathBuf, inner: RwLock, + auth_change_tx: watch::Sender, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, forced_chatgpt_workspace_id: RwLock>>, @@ -1320,12 +1322,14 @@ impl AuthManager { .await .ok() .flatten(); + let (auth_change_tx, _auth_change_rx) = watch::channel(0); Self { codex_home, inner: RwLock::new(CachedAuth { auth: managed_auth, permanent_refresh_failure: None, }), + auth_change_tx, enable_codex_api_key_env, auth_credentials_store_mode, forced_chatgpt_workspace_id: RwLock::new(None), @@ -1341,10 +1345,12 @@ impl AuthManager { auth: Some(auth), permanent_refresh_failure: None, }; + let (auth_change_tx, _auth_change_rx) = watch::channel(0); Arc::new(Self { codex_home: PathBuf::from("non-existent"), inner: RwLock::new(cached), + auth_change_tx, enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), @@ -1360,9 +1366,11 @@ impl AuthManager { auth: Some(auth), permanent_refresh_failure: None, }; + let (auth_change_tx, _auth_change_rx) = watch::channel(0); Arc::new(Self { codex_home, inner: RwLock::new(cached), + auth_change_tx, enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), @@ -1373,12 +1381,14 @@ impl AuthManager { } pub fn external_bearer_only(config: ModelProviderAuthInfo) -> Arc { + let (auth_change_tx, _auth_change_rx) = watch::channel(0); Arc::new(Self { codex_home: PathBuf::from("non-existent"), inner: RwLock::new(CachedAuth { auth: None, permanent_refresh_failure: None, }), + auth_change_tx, enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), @@ -1395,6 +1405,11 @@ impl AuthManager { self.inner.read().ok().and_then(|c| c.auth.clone()) } + /// Subscribes to cached auth changes that can affect request recovery. + pub fn auth_change_receiver(&self) -> watch::Receiver { + self.auth_change_tx.subscribe() + } + pub fn refresh_failure_for_auth(&self, auth: &CodexAuth) -> Option { self.inner.read().ok().and_then(|cached| { cached @@ -1537,6 +1552,9 @@ impl AuthManager { } tracing::info!("Reloaded auth, changed: {changed}"); guard.auth = new_auth; + if auth_changed_for_refresh { + self.auth_change_tx.send_modify(|revision| *revision += 1); + } changed } else { false diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 2f9f35427756..9c9a3da53cbc 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -20,7 +20,8 @@ use std::sync::Arc; /// Client-supplied configuration for a `codex` tool-call. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)] -#[serde(rename_all = "kebab-case")] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +#[schemars(deny_unknown_fields)] pub struct CodexToolCallParam { /// The *initial user prompt* to start the Codex conversation. pub prompt: String, @@ -29,10 +30,6 @@ pub struct CodexToolCallParam { #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, - /// Configuration profile from config.toml to specify default options. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile: Option, - /// Working directory for the session. If relative, it is resolved against /// the server process's current working directory. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -160,7 +157,6 @@ impl CodexToolCallParam { let Self { prompt, model, - profile, cwd, approval_policy, sandbox, @@ -173,7 +169,6 @@ impl CodexToolCallParam { // Build the `ConfigOverrides` recognized by codex-core. let overrides = ConfigOverrides { model, - config_profile: profile, cwd: cwd.map(PathBuf::from), approval_policy: approval_policy.map(Into::into), sandbox_mode: sandbox.map(Into::into), @@ -277,7 +272,14 @@ fn create_tool_input_schema( // in case any `$ref` leaks into the generated schema (even though we try // to inline subschemas). let mut input_schema = JsonObject::new(); - for key in ["properties", "required", "type", "$defs", "definitions"] { + for key in [ + "additionalProperties", + "properties", + "required", + "type", + "$defs", + "definitions", + ] { if let Some(value) = schema_object.remove(key) { input_schema.insert(key.to_string(), value); } @@ -309,6 +311,7 @@ mod tests { let expected_tool_json = serde_json::json!({ "description": "Run a Codex session. Accepts configuration parameters matching the Codex Config struct.", "inputSchema": { + "additionalProperties": false, "properties": { "approval-policy": { "description": "Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `on-request`, `never`.", @@ -345,10 +348,6 @@ mod tests { "description": "Optional override for the model name (e.g. 'gpt-5.2', 'gpt-5.2-codex').", "type": "string" }, - "profile": { - "description": "Configuration profile from config.toml to specify default options.", - "type": "string" - }, "prompt": { "description": "The *initial user prompt* to start the Codex conversation.", "type": "string" @@ -389,6 +388,20 @@ mod tests { assert_eq!(expected_tool_json, tool_json); } + #[test] + fn codex_tool_call_param_rejects_removed_profile_field() { + let err = serde_json::from_value::(serde_json::json!({ + "prompt": "hello", + "profile": "work" + })) + .expect_err("removed profile field should fail"); + + assert!( + err.to_string().contains("unknown field `profile`"), + "unexpected error: {err}" + ); + } + #[test] fn verify_codex_tool_reply_json_schema() { let tool = create_tool_for_codex_tool_call_reply_param(); diff --git a/codex-rs/model-provider/src/amazon_bedrock/mantle.rs b/codex-rs/model-provider/src/amazon_bedrock/mantle.rs index 7881845e4575..e5b497d355c2 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mantle.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mantle.rs @@ -7,10 +7,11 @@ use super::auth::BedrockAuthMethod; use super::auth::resolve_auth_method; const BEDROCK_MANTLE_SERVICE_NAME: &str = "bedrock-mantle"; -const BEDROCK_MANTLE_SUPPORTED_REGIONS: [&str; 12] = [ +const BEDROCK_MANTLE_SUPPORTED_REGIONS: [&str; 13] = [ "us-east-2", "us-east-1", "us-west-2", + "us-gov-west-1", "ap-southeast-3", "ap-south-1", "ap-northeast-1", @@ -72,6 +73,10 @@ mod tests { base_url("ap-northeast-1").expect("supported region"), "https://bedrock-mantle.ap-northeast-1.api.aws/openai/v1" ); + assert_eq!( + base_url("us-gov-west-1").expect("supported region"), + "https://bedrock-mantle.us-gov-west-1.api.aws/openai/v1" + ); } #[test] diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 2b76b27b8f63..1de1ed61796d 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -366,12 +366,14 @@ pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"]; pub const PROXY_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_ACTIVE"; 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"; #[cfg(any(target_os = "macos", test))] const GIT_SSH_COMMAND_ENV_KEY: &str = "GIT_SSH_COMMAND"; pub const PROXY_ENV_KEYS: &[&str] = &[ PROXY_ACTIVE_ENV_KEY, ALLOW_LOCAL_BINDING_ENV_KEY, ELECTRON_GET_USE_PROXY_ENV_KEY, + NODE_USE_ENV_PROXY_ENV_KEY, "HTTP_PROXY", "HTTPS_PROXY", "http_proxy", @@ -525,6 +527,8 @@ fn apply_proxy_env_overrides( ELECTRON_GET_USE_PROXY_ENV_KEY.to_string(), "true".to_string(), ); + // Node.js built-in HTTP clients only honor proxy environment variables when this is enabled. + env.insert(NODE_USE_ENV_PROXY_ENV_KEY.to_string(), "1".to_string()); // Keep HTTP_PROXY/HTTPS_PROXY as HTTP endpoints. A lot of clients break if // those vars contain SOCKS URLs. We only switch ALL_PROXY here. @@ -1016,6 +1020,7 @@ mod tests { env.get(ELECTRON_GET_USE_PROXY_ENV_KEY), Some(&"true".to_string()) ); + assert_eq!(env.get(NODE_USE_ENV_PROXY_ENV_KEY), Some(&"1".to_string())); #[cfg(target_os = "macos")] assert_eq!( env.get(GIT_SSH_COMMAND_ENV_KEY), diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 6a77125e70ea..1da6497eb077 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -10,7 +10,6 @@ use crate::metrics::MetricsConfig; use crate::metrics::MetricsError; use crate::metrics::PLUGIN_INSTALL_ELICITATION_SENT_METRIC; use crate::metrics::PLUGIN_INSTALL_SUGGESTION_METRIC; -use crate::metrics::PROFILE_USAGE_METRIC; use crate::metrics::RESPONSES_API_ENGINE_IAPI_TBT_DURATION_METRIC; use crate::metrics::RESPONSES_API_ENGINE_IAPI_TTFT_DURATION_METRIC; use crate::metrics::RESPONSES_API_ENGINE_SERVICE_TBT_DURATION_METRIC; @@ -447,11 +446,7 @@ impl SessionTelemetry { approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, mcp_servers: Vec<&str>, - active_profile: Option, ) { - if active_profile.is_some() { - self.counter(PROFILE_USAGE_METRIC, /*inc*/ 1, &[]); - } log_and_trace_event!( self, common: { @@ -472,11 +467,9 @@ impl SessionTelemetry { }, log: { mcp_servers = mcp_servers.join(", "), - active_profile = active_profile, }, trace: { mcp_server_count = mcp_servers.len() as i64, - active_profile_present = active_profile.is_some(), }, ); } diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 429db8116eb1..817545c87d23 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -36,7 +36,6 @@ pub const GOAL_USAGE_LIMITED_METRIC: &str = "codex.goal.usage_limited"; pub const GOAL_BLOCKED_METRIC: &str = "codex.goal.blocked"; pub const GOAL_TOKEN_COUNT_METRIC: &str = "codex.goal.token_count"; pub const GOAL_DURATION_SECONDS_METRIC: &str = "codex.goal.duration_s"; -pub const PROFILE_USAGE_METRIC: &str = "codex.profile.usage"; pub const PLUGIN_INSTALL_ELICITATION_SENT_METRIC: &str = "codex.plugins.install_elicitation.sent"; pub const PLUGIN_INSTALL_SUGGESTION_METRIC: &str = "codex.plugins.install_suggestion"; pub const CURATED_PLUGINS_STARTUP_SYNC_METRIC: &str = "codex.plugins.startup_sync"; diff --git a/codex-rs/otel/tests/suite/otel_export_routing_policy.rs b/codex-rs/otel/tests/suite/otel_export_routing_policy.rs index aba22a8e80f2..582d9792c55f 100644 --- a/codex-rs/otel/tests/suite/otel_export_routing_policy.rs +++ b/codex-rs/otel/tests/suite/otel_export_routing_policy.rs @@ -510,7 +510,6 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() { AskForApproval::Never, SandboxPolicy::DangerFullAccess, Vec::new(), - /*active_profile*/ None, ); manager.record_api_request( /*attempt*/ 1, diff --git a/codex-rs/plugin/src/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs index c76697366f01..2588ee0a7f94 100644 --- a/codex-rs/plugin/src/load_outcome.rs +++ b/codex-rs/plugin/src/load_outcome.rs @@ -126,6 +126,7 @@ impl PluginLoadOutcome { skill_roots.push(PluginSkillRoot { path: path.clone(), plugin_id: plugin.config_name.clone(), + plugin_root: plugin.root.clone(), }); } } @@ -245,6 +246,7 @@ mod tests { vec![PluginSkillRoot { path: shared_root, plugin_id: "zeta@test".to_string(), + plugin_root: test_path("zeta@test"), }] ); } diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index ef9c86cadf91..d7e953af0b75 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -7,6 +7,7 @@ use crate::exec_output::ExecToolCallOutput; use crate::network_policy::NetworkPolicyDecisionPayload; use crate::protocol::CodexErrorInfo; use crate::protocol::ErrorEvent; +use crate::protocol::RateLimitReachedType; use crate::protocol::RateLimitSnapshot; use crate::protocol::TruncationPolicy; use chrono::DateTime; @@ -451,6 +452,7 @@ pub struct UsageLimitReachedError { pub resets_at: Option>, pub rate_limits: Option>, pub promo_message: Option, + pub rate_limit_reached_type: Option, } impl std::fmt::Display for UsageLimitReachedError { @@ -470,6 +472,38 @@ impl std::fmt::Display for UsageLimitReachedError { ); } + if let Some(rate_limit_reached_type) = self.rate_limit_reached_type { + match rate_limit_reached_type { + RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + return write!( + f, + "Your workspace is out of credits. Add credits to continue." + ); + } + RateLimitReachedType::WorkspaceMemberCreditsDepleted => { + return write!( + f, + "Your workspace is out of credits. Ask your workspace owner to refill in order to continue." + ); + } + RateLimitReachedType::WorkspaceOwnerUsageLimitReached => { + return write!( + f, + "You hit your spend cap set in your workspace. Increase your spend cap to continue." + ); + } + RateLimitReachedType::WorkspaceMemberUsageLimitReached => { + return write!( + f, + "You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue." + ); + } + RateLimitReachedType::RateLimitReached => { + // Generic limits intentionally use the existing promo or plan copy below. + } + } + } + if let Some(promo_message) = &self.promo_message { return write!( f, diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index aef7478607c6..11bd26133b88 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -56,6 +56,7 @@ fn usage_limit_reached_error_formats_plus_plan() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -63,6 +64,44 @@ fn usage_limit_reached_error_formats_plus_plan() { ); } +#[test] +fn usage_limit_reached_error_formats_rate_limit_reached_types() { + let cases = [ + ( + RateLimitReachedType::RateLimitReached, + "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again later.", + ), + ( + RateLimitReachedType::WorkspaceOwnerCreditsDepleted, + "Your workspace is out of credits. Add credits to continue.", + ), + ( + RateLimitReachedType::WorkspaceMemberCreditsDepleted, + "Your workspace is out of credits. Ask your workspace owner to refill in order to continue.", + ), + ( + RateLimitReachedType::WorkspaceOwnerUsageLimitReached, + "You hit your spend cap set in your workspace. Increase your spend cap to continue.", + ), + ( + RateLimitReachedType::WorkspaceMemberUsageLimitReached, + "You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue.", + ), + ]; + + for (rate_limit_reached_type, expected) in cases { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Plus)), + resets_at: None, + rate_limits: Some(Box::new(rate_limit_snapshot())), + promo_message: None, + rate_limit_reached_type: Some(rate_limit_reached_type), + }; + + assert_eq!(err.to_string(), expected); + } +} + #[test] fn server_overloaded_maps_to_protocol() { let err = CodexErr::ServerOverloaded; @@ -177,6 +216,7 @@ fn usage_limit_reached_error_formats_free_plan() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -191,6 +231,7 @@ fn usage_limit_reached_error_formats_go_plan() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -205,6 +246,7 @@ fn usage_limit_reached_error_formats_default_when_none() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -223,6 +265,7 @@ fn usage_limit_reached_error_formats_team_plan() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!( "You've hit your usage limit. To get more access now, send a request to your admin or try again at {expected_time}." @@ -238,6 +281,7 @@ fn usage_limit_reached_error_formats_business_plan_without_reset() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -252,6 +296,7 @@ fn usage_limit_reached_error_formats_self_serve_business_usage_based_plan() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -266,6 +311,7 @@ fn usage_limit_reached_error_formats_enterprise_cbp_usage_based_plan() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -280,6 +326,7 @@ fn usage_limit_reached_error_formats_default_for_other_plans() { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; assert_eq!( err.to_string(), @@ -298,6 +345,7 @@ fn usage_limit_reached_error_formats_pro_plan_with_reset() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!( "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." @@ -324,6 +372,7 @@ fn usage_limit_reached_error_hides_upsell_for_non_codex_limit_name() { "Visit https://chatgpt.com/codex/settings/usage to purchase more credits" .to_string(), ), + rate_limit_reached_type: None, }; let expected = format!( "You've hit your usage limit for codex_other. Switch to another model now, or try again at {expected_time}." @@ -343,6 +392,7 @@ fn usage_limit_reached_includes_minutes_when_available() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!("You've hit your usage limit. Try again at {expected_time}."); assert_eq!(err.to_string(), expected); @@ -482,6 +532,7 @@ fn usage_limit_reached_includes_hours_and_minutes() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!( "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." @@ -502,6 +553,7 @@ fn usage_limit_reached_includes_days_hours_minutes() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!("You've hit your usage limit. Try again at {expected_time}."); assert_eq!(err.to_string(), expected); @@ -519,6 +571,7 @@ fn usage_limit_reached_less_than_minute() { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, + rate_limit_reached_type: None, }; let expected = format!("You've hit your usage limit. Try again at {expected_time}."); assert_eq!(err.to_string(), expected); @@ -538,6 +591,7 @@ fn usage_limit_reached_with_promo_message() { promo_message: Some( "To continue using Codex, start a free trial of today".to_string(), ), + rate_limit_reached_type: None, }; let expected = format!( "You've hit your usage limit. To continue using Codex, start a free trial of today, or try again at {expected_time}." diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b0f34bae4dbe..d3b8a9e820cb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1850,6 +1850,10 @@ pub struct TurnCompleteEvent { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnStartedEvent { pub turn_id: String, + // Persist for rollout consumers that correlate turns with telemetry traces. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub trace_id: Option, /// Unix timestamp (in seconds) when the turn started. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] @@ -1996,6 +2000,21 @@ pub enum RateLimitReachedType { WorkspaceMemberUsageLimitReached, } +impl FromStr for RateLimitReachedType { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "rate_limit_reached" => Ok(Self::RateLimitReached), + "workspace_owner_credits_depleted" => Ok(Self::WorkspaceOwnerCreditsDepleted), + "workspace_member_credits_depleted" => Ok(Self::WorkspaceMemberCreditsDepleted), + "workspace_owner_usage_limit_reached" => Ok(Self::WorkspaceOwnerUsageLimitReached), + "workspace_member_usage_limit_reached" => Ok(Self::WorkspaceMemberUsageLimitReached), + other => Err(format!("unknown rate limit reached type: {other}")), + } + } +} + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct RateLimitWindow { /// Percentage (0-100) of the window that has been consumed. diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index 7add4d05f5af..50657ab182be 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -70,6 +70,7 @@ impl TestToolServer { Self::echo_dash_tool(), Self::cwd_tool(), Self::sync_tool(), + Self::sync_readonly_tool(), Self::image_tool(), Self::image_scenario_tool(), sandbox_meta_tool, @@ -205,6 +206,12 @@ impl TestToolServer { })) .expect("sync tool output schema should deserialize"); tool.output_schema = Some(Arc::new(output_schema)); + tool + } + + fn sync_readonly_tool() -> Tool { + let mut tool = Self::sync_tool(); + tool.name = Cow::Borrowed("sync_readonly"); tool.annotations = Some(ToolAnnotations::new().read_only(true)); tool } @@ -551,6 +558,10 @@ impl ServerHandler for TestToolServer { let args = Self::parse_call_args::(&request, "sync")?; Self::sync_result(args).await } + "sync_readonly" => { + let args = Self::parse_call_args::(&request, "sync_readonly")?; + Self::sync_result(args).await + } other => Err(McpError::invalid_params( format!("unknown tool: {other}"), None, diff --git a/codex-rs/rollout/Cargo.toml b/codex-rs/rollout/Cargo.toml index ef5a8dc22a8d..50e5a8594a1a 100644 --- a/codex-rs/rollout/Cargo.toml +++ b/codex-rs/rollout/Cargo.toml @@ -24,6 +24,7 @@ codex-protocol = { workspace = true } codex-state = { workspace = true } codex-utils-path = { workspace = true } codex-utils-string = { workspace = true } +regex = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } time = { workspace = true, features = [ diff --git a/codex-rs/rollout/src/search.rs b/codex-rs/rollout/src/search.rs index 1773f5afb38b..911e80552a3f 100644 --- a/codex-rs/rollout/src/search.rs +++ b/codex-rs/rollout/src/search.rs @@ -9,6 +9,8 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::USER_MESSAGE_BEGIN; +use regex::Regex; +use regex::RegexBuilder; use tokio::io::AsyncBufReadExt; use tokio::process::Command; @@ -45,6 +47,7 @@ async fn ripgrep_rollout_paths( let output = match Command::new(rg_command) .arg("-l") .arg("--fixed-strings") + .arg("--ignore-case") .arg("--no-ignore") .arg("--glob") .arg("*.jsonl") @@ -88,6 +91,7 @@ async fn ripgrep_rollout_paths( async fn scan_rollout_paths(root: &Path, search_term: &str) -> io::Result> { let mut matches = HashSet::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 { @@ -107,7 +111,7 @@ async fn scan_rollout_paths(root: &Path, search_term: &str) -> io::Result io::Result io::Result { +async fn rollout_contains(path: &Path, search_term: &Regex) -> io::Result { let file = tokio::fs::File::open(path).await?; let mut lines = tokio::io::BufReader::new(file).lines(); while let Some(line) = lines.next_line().await? { - if line.contains(search_term) { + if search_term.is_match(line.as_str()) { return Ok(true); } } @@ -133,10 +137,11 @@ pub async fn first_rollout_content_match_snippet( ) -> io::Result> { let file = tokio::fs::File::open(path).await?; let mut lines = tokio::io::BufReader::new(file).lines(); - let json_search_term = json_escaped_search_term(search_term)?; + let json_search_term = case_insensitive_literal_regex(json_escaped_search_term(search_term)?)?; + let search_term = case_insensitive_literal_regex(search_term)?; while let Some(line) = lines.next_line().await? { - if line.contains(json_search_term.as_str()) - && let Some(snippet) = content_match_snippet(line.as_str(), search_term) + if json_search_term.is_match(line.as_str()) + && let Some(snippet) = content_match_snippet(line.as_str(), &search_term) { return Ok(Some(snippet)); } @@ -149,7 +154,14 @@ fn json_escaped_search_term(search_term: &str) -> io::Result { Ok(serialized[1..serialized.len() - 1].to_string()) } -fn content_match_snippet(jsonl_line: &str, search_term: &str) -> Option { +fn case_insensitive_literal_regex(search_term: impl AsRef) -> io::Result { + RegexBuilder::new(regex::escape(search_term.as_ref()).as_str()) + .case_insensitive(true) + .build() + .map_err(io::Error::other) +} + +fn content_match_snippet(jsonl_line: &str, search_term: &Regex) -> Option { let rollout_line = serde_json::from_str::(jsonl_line.trim()).ok()?; let text = conversation_text_from_item(&rollout_line.item)?; excerpt_around_match(text.as_str(), search_term) @@ -206,10 +218,11 @@ fn strip_user_message_prefix(text: &str) -> &str { } } -fn excerpt_around_match(text: &str, search_term: &str) -> Option { +fn excerpt_around_match(text: &str, search_term: &Regex) -> Option { let normalized = normalize_preview_text(text); - let match_start = normalized.find(search_term)?; - let match_end = match_start.saturating_add(search_term.len()); + let matched = search_term.find(normalized.as_str())?; + let match_start = matched.start(); + let match_end = matched.end(); let excerpt_start = char_start_before(normalized.as_str(), match_start, MATCH_CONTEXT_BEFORE_CHARS); let excerpt_end = char_end_after(normalized.as_str(), match_end, MATCH_CONTEXT_AFTER_CHARS); diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 9fdd6db90fb1..634327d007dc 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -271,7 +271,6 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R multi_agent_v2: MultiAgentV2Config::default(), features: Default::default(), suppress_unstable_features_warning: false, - active_profile: None, active_project: ProjectConfig { trust_level: None }, notices: Notice::default(), check_for_update_on_startup: false, diff --git a/codex-rs/tools/Cargo.toml b/codex-rs/tools/Cargo.toml index 334ce795803f..7cc0e348458f 100644 --- a/codex-rs/tools/Cargo.toml +++ b/codex-rs/tools/Cargo.toml @@ -17,6 +17,7 @@ codex-utils-absolute-path = { workspace = true } codex-utils-output-truncation = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-string = { workspace = true } +jsonptr = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ "base64", "macros", @@ -27,8 +28,10 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +urlencoding = { workspace = true } [dev-dependencies] +codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } [lib] diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index ecd880cc7aa9..02936d52d114 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -3,6 +3,10 @@ use serde::Serialize; use serde_json::Value as JsonValue; use serde_json::json; use std::collections::BTreeMap; +use std::collections::BTreeSet; + +const DEFINITION_TABLE_KEYS: [&str; 2] = ["$defs", "definitions"]; +const SCHEMA_CHILD_KEYS: [&str; 2] = ["items", "anyOf"]; /// Primitive JSON Schema type names we support in tool definitions. /// @@ -33,6 +37,8 @@ pub enum JsonSchemaType { /// Generic JSON-Schema subset needed for our tool definitions. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct JsonSchema { + #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")] + pub schema_ref: Option, #[serde(rename = "type", skip_serializing_if = "Option::is_none")] pub schema_type: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -52,6 +58,10 @@ pub struct JsonSchema { pub additional_properties: Option, #[serde(rename = "anyOf", skip_serializing_if = "Option::is_none")] pub any_of: Option>, + #[serde(rename = "$defs", skip_serializing_if = "Option::is_none")] + pub defs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub definitions: Option>, } impl JsonSchema { @@ -149,6 +159,8 @@ impl From for AdditionalProperties { pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result { let mut input_schema = input_schema.clone(); sanitize_json_schema(&mut input_schema); + prune_unreachable_definitions(&mut input_schema); + compact_large_tool_schema(&mut input_schema); let schema: JsonSchema = serde_json::from_value(input_schema)?; if matches!( schema.schema_type, @@ -159,10 +171,220 @@ pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result bool { + compact_normalized_schema_len(value) <= MAX_COMPACT_TOOL_SCHEMA_BYTES +} + +fn compact_normalized_schema_len(value: &JsonValue) -> usize { + serde_json::from_value::(value.clone()) + .and_then(|schema| serde_json::to_vec(&schema)) + .map(|json| json.len()) + .unwrap_or(0) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DefinitionTraversal { + Include, + Skip, +} + +fn for_each_schema_child( + map: &serde_json::Map, + definition_traversal: DefinitionTraversal, + visitor: &mut impl FnMut(&JsonValue), +) { + if let Some(properties) = map.get("properties") + && let Some(properties_map) = properties.as_object() + { + for value in properties_map.values() { + visitor(value); + } + } + + for key in SCHEMA_CHILD_KEYS { + if let Some(value) = map.get(key) { + visitor(value); + } + } + + if let Some(additional_properties) = map.get("additionalProperties") + && !matches!(additional_properties, JsonValue::Bool(_)) + { + visitor(additional_properties); + } + + if definition_traversal == DefinitionTraversal::Include { + for key in DEFINITION_TABLE_KEYS { + if let Some(definitions) = map.get(key) + && let Some(definitions_map) = definitions.as_object() + { + for value in definitions_map.values() { + visitor(value); + } + } + } + } +} + +fn strip_schema_descriptions(value: &mut JsonValue) { + match value { + JsonValue::Array(values) => { + for value in values { + strip_schema_descriptions(value); + } + } + JsonValue::Object(map) => { + map.remove("description"); + for_each_schema_child_mut(map, DefinitionTraversal::Include, &mut |value| { + strip_schema_descriptions(value); + }); + } + _ => {} + } +} + +fn for_each_schema_child_mut( + map: &mut serde_json::Map, + definition_traversal: DefinitionTraversal, + visitor: &mut impl FnMut(&mut JsonValue), +) { + if let Some(properties) = map.get_mut("properties") + && let Some(properties_map) = properties.as_object_mut() + { + for value in properties_map.values_mut() { + visitor(value); + } + } + + for key in SCHEMA_CHILD_KEYS { + if let Some(value) = map.get_mut(key) { + visitor(value); + } + } + + if let Some(additional_properties) = map.get_mut("additionalProperties") + && !matches!(additional_properties, JsonValue::Bool(_)) + { + visitor(additional_properties); + } + + if definition_traversal == DefinitionTraversal::Include { + for key in DEFINITION_TABLE_KEYS { + if let Some(definitions) = map.get_mut(key) + && let Some(definitions_map) = definitions.as_object_mut() + { + for value in definitions_map.values_mut() { + visitor(value); + } + } + } + } +} + +/// Replace local definition refs with empty schemas before dropping root +/// definition tables, so downstream behavior does not depend on how a schema +/// parser handles refs to missing definitions. +fn drop_schema_definitions(value: &mut JsonValue) { + rewrite_definition_refs_to_empty_schemas(value); + + let JsonValue::Object(map) = value else { + return; + }; + + for key in DEFINITION_TABLE_KEYS { + map.remove(key); + } +} + +fn rewrite_definition_refs_to_empty_schemas(value: &mut JsonValue) { + match value { + JsonValue::Array(values) => { + for value in values { + rewrite_definition_refs_to_empty_schemas(value); + } + } + JsonValue::Object(map) => { + if map + .get("$ref") + .and_then(JsonValue::as_str) + .and_then(parse_local_definition_ref) + .is_some() + { + *value = json!({}); + return; + } + + for_each_schema_child_mut(map, DefinitionTraversal::Skip, &mut |value| { + rewrite_definition_refs_to_empty_schemas(value); + }); + } + _ => {} + } +} + +fn collapse_deep_schema_objects(value: &mut JsonValue, depth: usize) { + match value { + JsonValue::Array(values) => { + for value in values { + collapse_deep_schema_objects(value, depth); + } + } + JsonValue::Object(map) => { + if depth >= MAX_COMPACT_TOOL_SCHEMA_DEPTH && is_complex_schema_object(map) { + *value = json!({}); + return; + } + + for_each_schema_child_mut(map, DefinitionTraversal::Skip, &mut |value| { + collapse_deep_schema_objects(value, depth + 1); + }); + } + _ => {} + } +} + +fn is_complex_schema_object(map: &serde_json::Map) -> bool { + SCHEMA_CHILD_KEYS.iter().any(|key| map.contains_key(*key)) + || map.contains_key("properties") + || map.contains_key("additionalProperties") + || map.contains_key("$ref") +} + /// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited /// schema representation. This function: /// - Ensures every typed schema object has a `"type"` when required. /// - Preserves explicit `anyOf`. +/// - Preserves `$ref` and reachable local `$defs` / `definitions`. /// - Collapses `const` into single-value `enum`. /// - Fills required child fields for object/array schema types, including /// nullable unions, with permissive defaults when absent. @@ -200,6 +422,9 @@ fn sanitize_json_schema(value: &mut JsonValue) { if let Some(value) = map.get_mut("anyOf") { sanitize_json_schema(value); } + for table in DEFINITION_TABLE_KEYS { + sanitize_schema_table(map, table); + } if let Some(const_value) = map.remove("const") { map.insert("enum".to_string(), JsonValue::Array(vec![const_value])); @@ -207,7 +432,7 @@ fn sanitize_json_schema(value: &mut JsonValue) { let mut schema_types = normalized_schema_types(map); - if schema_types.is_empty() && map.contains_key("anyOf") { + if schema_types.is_empty() && (map.contains_key("$ref") || map.contains_key("anyOf")) { return; } @@ -241,6 +466,29 @@ fn sanitize_json_schema(value: &mut JsonValue) { } } +/// Sanitize a schema definition table before deserializing into `JsonSchema`. +/// +/// Definition tables must be objects. Codex keeps valid definition tables and +/// recursively applies the same compatibility lowering used for inline schemas, +/// but drops malformed tables so `strict: false` tool registration degrades +/// gracefully instead of failing on an unreachable or invalid definition table. +fn sanitize_schema_table(map: &mut serde_json::Map, key: &str) { + let should_remove = match map.get_mut(key) { + Some(JsonValue::Object(definitions)) => { + for definition in definitions.values_mut() { + sanitize_json_schema(definition); + } + false + } + Some(_) => true, + None => false, + }; + + if should_remove { + map.remove(key); + } +} + fn ensure_default_children_for_schema_types( map: &mut serde_json::Map, schema_types: &[JsonSchemaPrimitiveType], @@ -257,6 +505,143 @@ fn ensure_default_children_for_schema_types( } } +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct DefinitionPointer { + table: &'static str, + name: String, +} + +/// Prune unused root definition entries to avoid sending tokens for definitions +/// the tool schema never references. +fn prune_unreachable_definitions(value: &mut JsonValue) { + let reachable = collect_reachable_definitions(value); + let JsonValue::Object(map) = value else { + return; + }; + + for table in DEFINITION_TABLE_KEYS { + prune_schema_table(map, table, &reachable); + } +} + +fn prune_schema_table( + map: &mut serde_json::Map, + table: &'static str, + reachable: &BTreeSet, +) { + let Some(JsonValue::Object(definitions)) = map.get_mut(table) else { + return; + }; + + definitions.retain(|name, _| { + reachable.contains(&DefinitionPointer { + table, + name: name.clone(), + }) + }); + + if definitions.is_empty() { + map.remove(table); + } +} + +fn collect_reachable_definitions(value: &JsonValue) -> BTreeSet { + let mut reachable = BTreeSet::new(); + let mut pending = Vec::new(); + + collect_refs_outside_definitions(value, &mut pending); + + while let Some(pointer) = pending.pop() { + if !reachable.insert(pointer.clone()) { + continue; + } + + if let Some(definition) = definition_for_pointer(value, &pointer) { + collect_refs(definition, &mut pending); + } + } + + reachable +} + +fn collect_refs_outside_definitions(value: &JsonValue, refs: &mut Vec) { + match value { + JsonValue::Array(values) => { + for value in values { + collect_refs_outside_definitions(value, refs); + } + } + JsonValue::Object(map) => { + collect_ref_from_map(map, refs); + for_each_schema_child(map, DefinitionTraversal::Skip, &mut |value| { + collect_refs_outside_definitions(value, refs); + }); + } + _ => {} + } +} + +fn collect_refs(value: &JsonValue, refs: &mut Vec) { + match value { + JsonValue::Array(values) => { + for value in values { + collect_refs(value, refs); + } + } + JsonValue::Object(map) => { + collect_ref_from_map(map, refs); + for value in map.values() { + collect_refs(value, refs); + } + } + _ => {} + } +} + +fn collect_ref_from_map( + map: &serde_json::Map, + refs: &mut Vec, +) { + if let Some(JsonValue::String(schema_ref)) = map.get("$ref") + && let Some(pointer) = parse_local_definition_ref(schema_ref) + { + refs.push(pointer); + } +} + +fn definition_for_pointer<'a>( + value: &'a JsonValue, + pointer: &DefinitionPointer, +) -> Option<&'a JsonValue> { + let JsonValue::Object(map) = value else { + return None; + }; + + map.get(pointer.table) + .and_then(JsonValue::as_object) + .and_then(|definitions| definitions.get(&pointer.name)) +} + +fn parse_local_definition_ref(schema_ref: &str) -> Option { + let fragment = schema_ref.strip_prefix('#')?; + let pointer = urlencoding::decode(fragment).ok()?; + let pointer = jsonptr::Pointer::parse(pointer.as_ref()).ok()?; + + let (table_token, pointer) = pointer.split_front()?; + let table = table_token.decoded(); + let table = DEFINITION_TABLE_KEYS + .into_iter() + .find(|candidate| table.as_ref() == *candidate)?; + + // Responses API non-strict mode accepts nested local refs such as + // `#/$defs/User/properties/name`, so keep the parent definition reachable. + let (name, _) = pointer.split_front()?; + Some(DefinitionPointer { + table, + name: name.decoded().into_owned(), + }) +} + fn normalized_schema_types( map: &serde_json::Map, ) -> Vec { diff --git a/codex-rs/tools/src/json_schema_tests.rs b/codex-rs/tools/src/json_schema_tests.rs index 5daaf048d09c..52b30138c117 100644 --- a/codex-rs/tools/src/json_schema_tests.rs +++ b/codex-rs/tools/src/json_schema_tests.rs @@ -779,6 +779,393 @@ fn parse_tool_input_schema_preserves_explicit_enum_type_union() { ); } +fn many_string_properties(count: usize) -> serde_json::Map { + (0..count) + .map(|index| { + ( + format!("field_{index:03}"), + serde_json::json!({ "type": "string" }), + ) + }) + .collect() +} + +#[test] +fn parse_large_tool_input_schema_stops_after_descriptions_when_under_budget() { + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "description": "x".repeat(4_500), + "properties": { + "metadata": { + "$ref": "#/$defs/metadata" + } + }, + "$defs": { + "metadata": { + "type": "string", + "description": "Metadata value" + } + } + })) + .expect("parse schema"); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "object", + "properties": { + "metadata": { + "$ref": "#/$defs/metadata" + } + }, + "$defs": { + "metadata": { + "type": "string" + } + } + }) + ); +} + +#[test] +fn parse_large_tool_input_schema_ignores_dropped_metadata_for_budget() { + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "event": { + "type": "object", + "title": "Calendar event", + "properties": { + "recurrence": { + "type": "object", + "examples": [ + { + "payload": "x".repeat(4_500) + } + ], + "properties": { + "pattern": { + "type": "string", + "title": "Recurrence pattern" + } + } + } + } + } + } + })) + .expect("parse schema"); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "object", + "properties": { + "event": { + "type": "object", + "properties": { + "recurrence": { + "type": "object", + "properties": { + "pattern": { + "type": "string" + } + } + } + } + } + } + }) + ); +} + +#[test] +fn parse_large_tool_input_schema_stops_after_dropping_root_definitions_when_under_budget() { + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "description": "x".repeat(4_500), + "properties": { + "event": { + "type": "object", + "description": "Calendar event", + "properties": { + "recurrence": { + "type": "object", + "description": "Recurrence settings", + "properties": { + "pattern": { + "type": "string", + "description": "Recurrence pattern" + } + } + } + } + }, + "metadata": { + "$ref": "#/$defs/metadata" + } + }, + "$defs": { + "metadata": { + "type": "object", + "description": "metadata object", + "properties": many_string_properties(/*count*/ 300) + } + } + })) + .expect("parse schema"); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "object", + "properties": { + "event": { + "type": "object", + "properties": { + "recurrence": { + "type": "object", + "properties": { + "pattern": { + "type": "string" + } + } + } + } + }, + "metadata": {} + } + }) + ); +} + +#[test] +fn parse_large_tool_input_schema_strips_descriptions_without_removing_description_property() { + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "description": "x".repeat(4_500), + "properties": { + "description": { + "type": "string", + "description": "User-facing description value" + }, + "metadata": { + "type": "object", + "description": "Metadata object", + "properties": { + "label": { + "type": "string", + "description": "Metadata label" + } + } + }, + "tags": { + "type": "array", + "description": "Tag list", + "items": { + "type": "string", + "description": "Tag value" + } + }, + "extras": { + "type": "object", + "additionalProperties": { + "type": "string", + "description": "Extra value" + } + }, + "choice": { + "description": "Choice value", + "anyOf": [ + { + "type": "string", + "description": "String choice" + }, + { + "type": "number", + "description": "Number choice" + } + ] + } + } + })) + .expect("parse schema"); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "object", + "properties": { + "choice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "description": { + "type": "string" + }, + "extras": { + "type": "object", + "properties": {}, + "additionalProperties": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }) + ); +} + +#[test] +fn parse_large_tool_input_schema_preserves_object_enum_literal_descriptions() { + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "description": "x".repeat(4_500), + "properties": { + "choice": { + "enum": [ + { + "description": "first literal", + "id": 1 + }, + { + "description": "second literal", + "id": 2 + } + ] + } + } + })) + .expect("parse schema"); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "object", + "properties": { + "choice": { + "type": "string", + "enum": [ + { + "description": "first literal", + "id": 1 + }, + { + "description": "second literal", + "id": 2 + } + ] + } + } + }) + ); +} + +#[test] +fn collapse_deep_schema_objects_traverses_schema_children() { + let mut schema = serde_json::json!({ + "type": "object", + "properties": { + "object_parent": { + "type": "object", + "properties": { + "complex": { + "type": "object", + "properties": { + "leaf": { "type": "string" } + } + }, + "scalar": { + "type": "string" + } + } + }, + "array_parent": { + "type": "array", + "items": { + "type": "object", + "properties": { + "leaf": { "type": "string" } + } + } + }, + "map_parent": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "leaf": { "type": "string" } + } + } + }, + "union_parent": { + "anyOf": [ + { + "type": "object", + "properties": { + "leaf": { "type": "string" } + } + }, + { "type": "string" } + ] + } + } + }); + + super::collapse_deep_schema_objects(&mut schema, /*depth*/ 0); + + assert_eq!( + schema, + serde_json::json!({ + "type": "object", + "properties": { + "object_parent": { + "type": "object", + "properties": { + "complex": {}, + "scalar": { + "type": "string" + } + } + }, + "array_parent": { + "type": "array", + "items": {} + }, + "map_parent": { + "type": "object", + "additionalProperties": {} + }, + "union_parent": { + "anyOf": [ + {}, + { "type": "string" } + ] + } + } + }) + ); +} + #[test] fn parse_tool_input_schema_preserves_string_enum_constraints() { // Example schema shape: @@ -848,3 +1235,538 @@ fn parse_tool_input_schema_preserves_string_enum_constraints() { ) ); } + +#[test] +fn parse_tool_input_schema_preserves_refs_and_prunes_unreachable_defs() { + // Example schema shape: + // { + // "type": "object", + // "properties": { "user": { "$ref": "#/$defs/User" } }, + // "$defs": { + // "User": { "type": "object", "properties": { "name": { "type": "string" } } }, + // "Unused": { "type": "string" } + // } + // } + // + // Expected normalization behavior: + // - Local `$ref` is preserved as a schema hint. + // - Reachable `$defs` entries stay attached to the root schema. + // - Unreachable `$defs` entries are pruned. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "user": {"$ref": "#/$defs/User"} + }, + "$defs": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + }, + "Unused": {"type": "string"} + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema { + schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)), + properties: Some(BTreeMap::from([( + "user".to_string(), + JsonSchema { + schema_ref: Some("#/$defs/User".to_string()), + ..Default::default() + }, + )])), + defs: Some(BTreeMap::from([( + "User".to_string(), + JsonSchema::object( + BTreeMap::from([( + "name".to_string(), + JsonSchema::string(/*description*/ None), + )]), + /*required*/ None, + /*additional_properties*/ None, + ), + )])), + ..Default::default() + } + ); +} + +#[test] +fn parse_tool_input_schema_preserves_refs_from_properties_named_def_tables() { + // Example schema shape: + // { + // "type": "object", + // "properties": { + // "$defs": { "$ref": "#/$defs/User" } + // }, + // "$defs": { "User": { "type": "string" }, "Unused": { "type": "boolean" } } + // } + // + // Expected normalization behavior: + // - A property named like the `$defs` keyword is treated as a user field + // while traversing `properties`. + // - Refs from that property schema still mark root definitions reachable. + // - Unreferenced root definitions are still pruned. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "$defs": {"$ref": "#/$defs/User"} + }, + "$defs": { + "User": {"type": "string"}, + "Unused": {"type": "boolean"} + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema { + schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)), + properties: Some(BTreeMap::from([( + "$defs".to_string(), + JsonSchema { + schema_ref: Some("#/$defs/User".to_string()), + ..Default::default() + }, + )])), + defs: Some(BTreeMap::from([( + "User".to_string(), + JsonSchema::string(/*description*/ None), + )])), + ..Default::default() + } + ); +} + +#[test] +fn parse_tool_input_schema_collects_refs_from_schema_child_keywords() { + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "items_holder": { + "type": "array", + "items": {"$ref": "#/$defs/Item"} + }, + "map_holder": { + "type": "object", + "additionalProperties": {"$ref": "#/$defs/Extra"} + }, + "choice": { + "anyOf": [ + {"$ref": "#/$defs/Choice"}, + {"type": "string"} + ] + } + }, + "$defs": { + "Choice": {"type": "boolean"}, + "Extra": {"type": "number"}, + "Item": {"type": "string"}, + "Unused": {"type": "null"} + } + })) + .expect("parse schema"); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "object", + "properties": { + "choice": { + "anyOf": [ + {"$ref": "#/$defs/Choice"}, + {"type": "string"} + ] + }, + "items_holder": { + "type": "array", + "items": {"$ref": "#/$defs/Item"} + }, + "map_holder": { + "type": "object", + "properties": {}, + "additionalProperties": {"$ref": "#/$defs/Extra"} + } + }, + "$defs": { + "Choice": {"type": "boolean"}, + "Extra": {"type": "number"}, + "Item": {"type": "string"} + } + }) + ); +} + +#[test] +fn parse_tool_input_schema_handles_cyclic_local_refs() { + // Example schema shape: + // { + // "type": "object", + // "properties": { "node": { "$ref": "#/$defs/Node" } }, + // "$defs": { + // "Node": { + // "type": "object", + // "properties": { "next": { "$ref": "#/$defs/Node" } } + // } + // } + // } + // + // Expected normalization behavior: + // - Recursive refs are preserved. + // - Pruning traversal terminates after visiting each local target once. + // - Responses API handles this recursive local-ref shape correctly. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "node": {"$ref": "#/$defs/Node"} + }, + "$defs": { + "Node": { + "type": "object", + "properties": { + "next": {"$ref": "#/$defs/Node"} + } + } + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema { + schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)), + properties: Some(BTreeMap::from([( + "node".to_string(), + JsonSchema { + schema_ref: Some("#/$defs/Node".to_string()), + ..Default::default() + }, + )])), + defs: Some(BTreeMap::from([( + "Node".to_string(), + JsonSchema::object( + BTreeMap::from([( + "next".to_string(), + JsonSchema { + schema_ref: Some("#/$defs/Node".to_string()), + ..Default::default() + }, + )]), + /*required*/ None, + /*additional_properties*/ None, + ), + )])), + ..Default::default() + } + ); +} + +#[test] +fn parse_tool_input_schema_preserves_legacy_definitions() { + // Example schema shape: + // { + // "type": "object", + // "properties": { "user": { "$ref": "#/definitions/User" } }, + // "definitions": { + // "User": { "type": "object", "properties": { "profile": { "$ref": "#/definitions/Profile" } } }, + // "Profile": { "type": "object", "properties": { "name": { "type": "string" } } } + // } + // } + // + // Expected normalization behavior: + // - Codex preserves legacy `definitions`. + // - Reachability follows refs through the legacy definition table. + // - Unreachable legacy definition entries are pruned. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "user": {"$ref": "#/definitions/User"} + }, + "definitions": { + "User": { + "type": "object", + "properties": { + "profile": {"$ref": "#/definitions/Profile"} + } + }, + "Profile": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + }, + "Unused": {"type": "string"} + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema { + schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)), + properties: Some(BTreeMap::from([( + "user".to_string(), + JsonSchema { + schema_ref: Some("#/definitions/User".to_string()), + ..Default::default() + }, + )])), + definitions: Some(BTreeMap::from([ + ( + "Profile".to_string(), + JsonSchema::object( + BTreeMap::from([( + "name".to_string(), + JsonSchema::string(/*description*/ None), + )]), + /*required*/ None, + /*additional_properties*/ None, + ), + ), + ( + "User".to_string(), + JsonSchema::object( + BTreeMap::from([( + "profile".to_string(), + JsonSchema { + schema_ref: Some("#/definitions/Profile".to_string()), + ..Default::default() + }, + )]), + /*required*/ None, + /*additional_properties*/ None, + ), + ), + ])), + ..Default::default() + } + ); +} + +#[test] +fn parse_tool_input_schema_preserves_unresolved_and_external_refs() { + // Example schema shape: + // { + // "type": "object", + // "properties": { + // "missing": { "$ref": "#/$defs/Missing" }, + // "remote": { "$ref": "https://example.com/schema.json" } + // }, + // "$defs": { "Unused": { "type": "string" } } + // } + // + // Expected normalization behavior: + // - Unresolved local refs and external refs are preserved. + // - Unreachable local definitions are still pruned. + // - Responses API handles these refs correctly during downstream validation. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "missing": {"$ref": "#/$defs/Missing"}, + "remote": {"$ref": "https://example.com/schema.json"} + }, + "$defs": { + "Unused": {"type": "string"} + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema { + schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)), + properties: Some(BTreeMap::from([ + ( + "missing".to_string(), + JsonSchema { + schema_ref: Some("#/$defs/Missing".to_string()), + ..Default::default() + }, + ), + ( + "remote".to_string(), + JsonSchema { + schema_ref: Some("https://example.com/schema.json".to_string()), + ..Default::default() + }, + ), + ])), + ..Default::default() + } + ); +} + +#[test] +fn parse_tool_input_schema_preserves_nested_defs_ref_parent() { + // Example schema shape: + // { + // "type": "object", + // "properties": { "name": { "$ref": "#/$defs/User/properties/name" } }, + // "$defs": { + // "User": { "type": "object", "properties": { "name": { "type": "string" } } }, + // "name": { "type": "string" }, + // "Unused": { "type": "boolean" } + // } + // } + // + // Expected normalization behavior: + // - The nested JSON Pointer ref remains unchanged. + // - The parent root definition is retained so the local ref does not dangle. + // - Unreferenced root definitions are still pruned. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "name": {"$ref": "#/$defs/User/properties/name"} + }, + "$defs": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + }, + "name": {"type": "string"}, + "Unused": {"type": "boolean"} + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema { + schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)), + properties: Some(BTreeMap::from([( + "name".to_string(), + JsonSchema { + schema_ref: Some("#/$defs/User/properties/name".to_string()), + ..Default::default() + }, + )])), + defs: Some(BTreeMap::from([( + "User".to_string(), + JsonSchema::object( + BTreeMap::from([( + "name".to_string(), + JsonSchema::string(/*description*/ None), + )]), + /*required*/ None, + /*additional_properties*/ None, + ), + )])), + ..Default::default() + } + ); +} + +#[test] +fn parse_tool_input_schema_preserves_percent_encoded_definition_refs() { + // Example schema shape: + // { + // "type": "object", + // "properties": { + // "user": { "$ref": "#/$defs/User%20Name" }, + // "profile": { "$ref": "#/%24defs/Profile%7E0Name" } + // }, + // "$defs": { + // "User Name": { "type": "string" }, + // "Profile~Name": { "type": "string" }, + // "Unused": { "type": "boolean" } + // } + // } + // + // Expected normalization behavior: + // - URI fragment percent encoding is decoded before JSON Pointer `~` + // escaping, per RFC 6901 section 6. + // - The original `$ref` strings are preserved, but their definition + // targets are recognized as reachable and retained. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "user": {"$ref": "#/$defs/User%20Name"}, + "profile": {"$ref": "#/%24defs/Profile%7E0Name"} + }, + "$defs": { + "User Name": {"type": "string"}, + "Profile~Name": {"type": "string"}, + "Unused": {"type": "boolean"} + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema { + schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)), + properties: Some(BTreeMap::from([ + ( + "profile".to_string(), + JsonSchema { + schema_ref: Some("#/%24defs/Profile%7E0Name".to_string()), + ..Default::default() + }, + ), + ( + "user".to_string(), + JsonSchema { + schema_ref: Some("#/$defs/User%20Name".to_string()), + ..Default::default() + }, + ), + ])), + defs: Some(BTreeMap::from([ + ( + "Profile~Name".to_string(), + JsonSchema::string(/*description*/ None), + ), + ( + "User Name".to_string(), + JsonSchema::string(/*description*/ None), + ), + ])), + ..Default::default() + } + ); +} + +#[test] +fn parse_tool_input_schema_drops_malformed_definition_tables() { + // Example schema shape: + // { + // "type": "object", + // "properties": { "user": { "$ref": "#/$defs/User" } }, + // "$defs": ["not", "an", "object"] + // } + // + // Expected normalization behavior: + // - Malformed `$defs` tables are dropped instead of rejecting the schema. + // - The unresolved local ref remains visible to the model. + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "user": {"$ref": "#/$defs/User"} + }, + "$defs": ["not", "an", "object"] + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema { + schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)), + properties: Some(BTreeMap::from([( + "user".to_string(), + JsonSchema { + schema_ref: Some("#/$defs/User".to_string()), + ..Default::default() + }, + )])), + ..Default::default() + } + ); +} diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 7b64776dca53..c141bfb37aa1 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -57,6 +57,7 @@ pub use responses_api::dynamic_tool_to_responses_api_tool; pub use responses_api::mcp_tool_to_deferred_responses_api_tool; 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::ToolCall; pub use tool_config::ShellCommandBackendConfig; pub use tool_config::ToolEnvironmentMode; diff --git a/codex-rs/tools/src/tool_call.rs b/codex-rs/tools/src/tool_call.rs index f92c92f97997..32d428648fcc 100644 --- a/codex-rs/tools/src/tool_call.rs +++ b/codex-rs/tools/src/tool_call.rs @@ -1,7 +1,27 @@ use crate::FunctionCallError; use crate::ToolName; use crate::ToolPayload; +use codex_protocol::models::ResponseItem; use codex_utils_output_truncation::TruncationPolicy; +use std::sync::Arc; + +/// Raw response history snapshot available when an extension tool is invoked. +#[derive(Clone, Debug, Default)] +pub struct ConversationHistory { + items: Arc<[ResponseItem]>, +} + +impl ConversationHistory { + pub fn new(items: Vec) -> Self { + Self { + items: items.into(), + } + } + + pub fn items(&self) -> &[ResponseItem] { + &self.items + } +} // TODO: this is temporary and will disappear in the next PR (as we make codex-extension-api generic on Invocation. #[derive(Clone, Debug)] @@ -10,6 +30,7 @@ pub struct ToolCall { pub call_id: String, pub tool_name: ToolName, pub truncation_policy: TruncationPolicy, + pub conversation_history: ConversationHistory, pub payload: ToolPayload, } diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/google_calendar.json b/codex-rs/tools/tests/fixtures/json_schema_policy/google_calendar.json new file mode 100644 index 000000000000..9673ecf59671 --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/google_calendar.json @@ -0,0 +1,85 @@ +{ + "source": "standard/google_calendar/tools", + "tools": [ + { + "name": "google_calendar_create_event", + "description": "Create a calendar event.", + "input_schema": { + "type": "object", + "properties": { + "event": { + "$ref": "#/definitions/Event" + }, + "notify_attendees": { + "type": "boolean", + "description": "Whether attendees should receive notifications." + } + }, + "required": [ + "event" + ], + "definitions": { + "Event": { + "type": "object", + "description": "Calendar event payload.", + "properties": { + "title": { + "type": "string", + "description": "Event title." + }, + "start": { + "anyOf": [ + { + "$ref": "#/definitions/DateTime" + }, + { + "type": "null" + } + ] + } + } + }, + "DateTime": { + "type": "object", + "description": "Calendar date-time.", + "properties": { + "dateTime": { + "type": "string", + "format": "date-time", + "description": "RFC3339 date-time." + }, + "timeZone": { + "type": "string", + "enum": [ + "UTC", + "America/Los_Angeles" + ], + "description": "IANA time zone." + } + } + }, + "UnusedCalendarResource": { + "type": "string", + "description": "Unreachable calendar resource." + } + } + }, + "expected_preserved": [ + { + "pointer": "/properties/event/$ref", + "value": "#/definitions/Event" + }, + { + "pointer": "/definitions/DateTime/properties/timeZone/enum/1", + "value": "America/Los_Angeles" + } + ], + "expected_pruned": [ + "/definitions/UnusedCalendarResource" + ], + "expected_dropped_fields": [ + "/definitions/DateTime/properties/dateTime/format" + ] + } + ] +} diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json b/codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json new file mode 100644 index 000000000000..bf37296cc029 --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json @@ -0,0 +1,65 @@ +{ + "source": "standard/google_drive/tools", + "tools": [ + { + "name": "google_drive_copy_file", + "description": "Copy a Google Drive file.", + "input_schema": { + "type": "object", + "properties": { + "file": { + "description": "File selector.", + "oneOf": [ + { + "type": "string", + "description": "A Drive file ID." + }, + { + "type": "object", + "properties": { + "shared_drive_id": { + "type": "string", + "description": "Shared drive identifier." + } + } + } + ] + }, + "metadata": { + "description": "Optional copy metadata.", + "allOf": [ + { + "type": "object", + "description": "Base metadata.", + "properties": { + "source": { + "type": "string", + "description": "Metadata source." + } + } + } + ] + }, + "title": { + "type": "string", + "description": "Copied file title." + } + }, + "required": [ + "file" + ] + }, + "expected_preserved": [ + { + "pointer": "/properties/title/description", + "value": "Copied file title." + } + ], + "expected_pruned": [], + "expected_dropped_fields": [ + "/properties/file/oneOf", + "/properties/metadata/allOf" + ] + } + ] +} diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/microsoft_outlook_email.json b/codex-rs/tools/tests/fixtures/json_schema_policy/microsoft_outlook_email.json new file mode 100644 index 000000000000..bf0aff4118ca --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/microsoft_outlook_email.json @@ -0,0 +1,90 @@ +{ + "source": "golden/microsoft_outlook_email/tools", + "tools": [ + { + "name": "microsoft_outlook_email_send", + "description": "Send an Outlook email.", + "input_schema": { + "type": "object", + "properties": { + "message_id": { + "$ref": "#/$defs/Message/properties/id" + }, + "attachment": { + "type": "array", + "items": { + "$ref": "#/$defs/Attachment" + } + } + }, + "$defs": { + "Message": { + "type": "object", + "description": "Outlook message.", + "properties": { + "id": { + "type": "string", + "description": "Message identifier." + }, + "sender": { + "$ref": "#/$defs/EmailAddress" + }, + "importance": { + "type": "string", + "enum": [ + "low", + "normal", + "high" + ], + "description": "Message importance." + } + } + }, + "Attachment": { + "type": "object", + "description": "Email attachment.", + "properties": { + "name": { + "type": "string", + "description": "Attachment file name." + } + } + }, + "EmailAddress": { + "type": "object", + "description": "Email address object.", + "properties": { + "address": { + "type": "string", + "description": "SMTP address." + } + } + }, + "GiantUnusedPayload": { + "type": "object", + "description": "Representative unreachable Outlook payload.", + "properties": { + "opaque": { + "type": "string" + } + } + } + } + }, + "expected_preserved": [ + { + "pointer": "/properties/message_id/$ref", + "value": "#/$defs/Message/properties/id" + }, + { + "pointer": "/$defs/Message/properties/importance/enum/2", + "value": "high" + } + ], + "expected_pruned": [ + "/$defs/GiantUnusedPayload" + ], + "expected_dropped_fields": [] + } + ] +} diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/notion.json b/codex-rs/tools/tests/fixtures/json_schema_policy/notion.json new file mode 100644 index 000000000000..49a362d49e58 --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/notion.json @@ -0,0 +1,72 @@ +{ + "source": "golden/notion/tools", + "tools": [ + { + "name": "notion_fetch_page", + "description": "Fetch a Notion page.", + "input_schema": { + "type": "object", + "properties": { + "page": { + "$ref": "#/$defs/Page%20Ref" + }, + "filter": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/Filter" + } + }, + "content_mode": { + "description": "How much content to return.", + "anyOf": [ + { + "type": "string", + "enum": [ + "summary", + "full" + ] + }, + { + "type": "null" + } + ] + } + }, + "$defs": { + "Page Ref": { + "type": "string", + "description": "Notion page ID or URL." + }, + "Filter": { + "type": "object", + "description": "Filter object.", + "properties": { + "created_by": { + "type": "string", + "description": "Creator user ID." + } + } + }, + "UnusedDatabase": { + "type": "object", + "description": "Unreachable Notion database schema." + } + } + }, + "expected_preserved": [ + { + "pointer": "/properties/page/$ref", + "value": "#/$defs/Page%20Ref" + }, + { + "pointer": "/properties/content_mode/anyOf/0/enum/0", + "value": "summary" + } + ], + "expected_pruned": [ + "/$defs/UnusedDatabase" + ], + "expected_dropped_fields": [] + } + ] +} diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/oversized_notion_create_page_input_schema.json b/codex-rs/tools/tests/fixtures/json_schema_policy/oversized_notion_create_page_input_schema.json new file mode 100644 index 000000000000..b4957a1830bc --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/oversized_notion_create_page_input_schema.json @@ -0,0 +1,1124 @@ +{ + "source": "golden/notion/tools", + "tools": [ + { + "name": "create_page", + "description": "Create a Notion page.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "create_page.input", + "type": "object", + "description": "Input schema for create_page.", + "$defs": { + "rich_text": { + "type": "object", + "description": "Notion rich text object. This schema captures the stable high-value fields and permits provider extensions.", + "properties": { + "type": { + "type": "string", + "description": "Rich text discriminator such as `text`, `mention`, or `equation`." + }, + "plain_text": { + "type": "string", + "description": "Flattened plain-text representation." + }, + "href": { + "type": [ + "string", + "null" + ], + "description": "Optional hyperlink target when present." + }, + "annotations": { + "type": "object", + "description": "Text annotations applied to this run.", + "properties": { + "bold": { + "type": "boolean" + }, + "italic": { + "type": "boolean" + }, + "strikethrough": { + "type": "boolean" + }, + "underline": { + "type": "boolean" + }, + "code": { + "type": "boolean" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": true + }, + "text": { + "type": "object", + "description": "Text payload for `type=text` entries.", + "properties": { + "content": { + "type": "string" + }, + "link": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + } + ] + } + }, + "additionalProperties": true + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "icon": { + "type": "object", + "description": "Notion icon union. Keep provider variants explicit and allow future variants.", + "properties": { + "type": { + "type": "string", + "description": "Icon discriminator such as `emoji`, `external`, `file_upload`, `custom_emoji`, or `icon`." + }, + "emoji": { + "type": "string", + "description": "Emoji character for `type=emoji`." + }, + "name": { + "type": "string", + "description": "Named native icon or custom emoji name when applicable." + }, + "color": { + "type": "string", + "description": "Optional icon color for native icon variants." + }, + "external": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": true + }, + "file_upload": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "additionalProperties": true + }, + "custom_emoji": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": true + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "file_ref": { + "type": "object", + "description": "Notion file union used by cover and attachments.", + "properties": { + "type": { + "type": "string", + "description": "File discriminator such as `external`, `file`, or `file_upload`." + }, + "name": { + "type": "string" + }, + "external": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url" + ], + "additionalProperties": true + }, + "file": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "expiry_time": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": true + }, + "file_upload": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": true + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "parent": { + "type": "object", + "description": "Notion parent reference. The exact allowed variants depend on the endpoint.", + "properties": { + "type": { + "type": "string", + "description": "Parent discriminator such as `page_id`, `data_source_id`, `database_id`, `block_id`, or `workspace`." + }, + "page_id": { + "type": "string" + }, + "data_source_id": { + "type": "string" + }, + "database_id": { + "type": "string" + }, + "block_id": { + "type": "string" + }, + "workspace": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "date_value": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "ISO 8601 date or datetime string." + }, + "end": { + "type": [ + "string", + "null" + ] + }, + "time_zone": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "start" + ], + "additionalProperties": false + }, + "user": { + "type": "object", + "description": "Notion user or bot object.", + "properties": { + "object": { + "type": "string", + "const": "user" + }, + "id": { + "type": "string" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "avatar_url": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "person", + "bot" + ] + }, + "person": { + "type": "object", + "properties": { + "email": { + "type": [ + "string", + "null" + ], + "format": "email" + } + }, + "additionalProperties": true + }, + "bot": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "object", + "id", + "type" + ], + "additionalProperties": true + }, + "property_value": { + "type": "object", + "description": "Provider-native page property value object. This schema captures common property families and leaves room for future Notion property variants.", + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "rich_text": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "number": { + "type": [ + "number", + "null" + ] + }, + "checkbox": { + "type": "boolean" + }, + "url": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "phone_number": { + "type": [ + "string", + "null" + ] + }, + "date": { + "anyOf": [ + { + "$ref": "#/$defs/date_value" + }, + { + "type": "null" + } + ] + }, + "select": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": true + } + ] + }, + "status": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": true + } + ] + }, + "multi_select": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": true + } + }, + "people": { + "type": "array", + "items": { + "$ref": "#/$defs/user" + } + }, + "relation": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + } + }, + "files": { + "type": "array", + "items": { + "$ref": "#/$defs/file_ref" + } + }, + "created_time": { + "type": "string", + "format": "date-time" + }, + "last_edited_time": { + "type": "string", + "format": "date-time" + }, + "formula": { + "type": "object", + "additionalProperties": true + }, + "rollup": { + "type": "object", + "additionalProperties": true + }, + "verification": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "page": { + "type": "object", + "description": "Notion page object, modeled as a practical schema-authoring subset.", + "properties": { + "object": { + "type": "string", + "const": "page" + }, + "id": { + "type": "string" + }, + "created_time": { + "type": "string", + "format": "date-time" + }, + "last_edited_time": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "$ref": "#/$defs/user" + }, + "last_edited_by": { + "$ref": "#/$defs/user" + }, + "cover": { + "anyOf": [ + { + "$ref": "#/$defs/file_ref" + }, + { + "type": "null" + } + ] + }, + "icon": { + "anyOf": [ + { + "$ref": "#/$defs/icon" + }, + { + "type": "null" + } + ] + }, + "parent": { + "$ref": "#/$defs/parent" + }, + "archived": { + "type": "boolean" + }, + "in_trash": { + "type": "boolean" + }, + "is_locked": { + "type": "boolean" + }, + "url": { + "type": "string", + "format": "uri" + }, + "properties": { + "type": "object", + "description": "Page property map keyed by property name.", + "additionalProperties": { + "$ref": "#/$defs/property_value" + } + } + }, + "required": [ + "object", + "id", + "parent", + "properties" + ], + "additionalProperties": true + }, + "text_block_payload": { + "type": "object", + "properties": { + "rich_text": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "color": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/block" + } + } + }, + "additionalProperties": true + }, + "block": { + "type": "object", + "description": "Notion block object. The schema preserves the top-level discriminator and important typed payload families while allowing future provider expansion.", + "properties": { + "object": { + "type": "string", + "const": "block" + }, + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "has_children": { + "type": "boolean" + }, + "archived": { + "type": "boolean" + }, + "in_trash": { + "type": "boolean" + }, + "created_time": { + "type": "string", + "format": "date-time" + }, + "last_edited_time": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "$ref": "#/$defs/user" + }, + "last_edited_by": { + "$ref": "#/$defs/user" + }, + "parent": { + "$ref": "#/$defs/parent" + }, + "paragraph": { + "$ref": "#/$defs/text_block_payload" + }, + "heading_1": { + "$ref": "#/$defs/text_block_payload" + }, + "heading_2": { + "$ref": "#/$defs/text_block_payload" + }, + "heading_3": { + "$ref": "#/$defs/text_block_payload" + }, + "heading_4": { + "$ref": "#/$defs/text_block_payload" + }, + "bulleted_list_item": { + "$ref": "#/$defs/text_block_payload" + }, + "numbered_list_item": { + "$ref": "#/$defs/text_block_payload" + }, + "toggle": { + "$ref": "#/$defs/text_block_payload" + }, + "to_do": { + "allOf": [ + { + "$ref": "#/$defs/text_block_payload" + }, + { + "type": "object", + "properties": { + "checked": { + "type": "boolean" + } + }, + "additionalProperties": true + } + ] + }, + "callout": { + "allOf": [ + { + "$ref": "#/$defs/text_block_payload" + }, + { + "type": "object", + "properties": { + "icon": { + "anyOf": [ + { + "$ref": "#/$defs/icon" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + } + ] + }, + "code": { + "type": "object", + "properties": { + "rich_text": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "caption": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "language": { + "type": "string" + } + }, + "additionalProperties": true + }, + "image": { + "type": "object", + "properties": { + "caption": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "type": { + "type": "string" + }, + "file": { + "$ref": "#/$defs/file_ref" + }, + "external": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "tab": { + "type": "object", + "additionalProperties": true + }, + "meeting_notes": { + "type": "object", + "additionalProperties": true + }, + "unsupported": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "object", + "id", + "type" + ], + "additionalProperties": true + }, + "property_schema": { + "type": "object", + "description": "Data source property schema entry.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + } + ] + }, + "type": { + "type": "string" + }, + "number": { + "type": "object", + "additionalProperties": true + }, + "select": { + "type": "object", + "additionalProperties": true + }, + "multi_select": { + "type": "object", + "additionalProperties": true + }, + "status": { + "type": "object", + "additionalProperties": true + }, + "date": { + "type": "object", + "additionalProperties": true + }, + "people": { + "type": "object", + "additionalProperties": true + }, + "files": { + "type": "object", + "additionalProperties": true + }, + "checkbox": { + "type": "object", + "additionalProperties": true + }, + "url": { + "type": "object", + "additionalProperties": true + }, + "email": { + "type": "object", + "additionalProperties": true + }, + "phone_number": { + "type": "object", + "additionalProperties": true + }, + "formula": { + "type": "object", + "additionalProperties": true + }, + "relation": { + "type": "object", + "additionalProperties": true + }, + "rollup": { + "type": "object", + "additionalProperties": true + }, + "created_time": { + "type": "object", + "additionalProperties": true + }, + "last_edited_time": { + "type": "object", + "additionalProperties": true + }, + "created_by": { + "type": "object", + "additionalProperties": true + }, + "last_edited_by": { + "type": "object", + "additionalProperties": true + }, + "verification": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "data_source": { + "type": "object", + "description": "Notion data source object representing a table under a database.", + "properties": { + "object": { + "type": "string", + "const": "data_source" + }, + "id": { + "type": "string" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "icon": { + "anyOf": [ + { + "$ref": "#/$defs/icon" + }, + { + "type": "null" + } + ] + }, + "parent": { + "$ref": "#/$defs/parent" + }, + "url": { + "type": "string", + "format": "uri" + }, + "properties": { + "type": "object", + "description": "Schema map keyed by property name or id.", + "additionalProperties": { + "$ref": "#/$defs/property_schema" + } + }, + "in_trash": { + "type": "boolean" + } + }, + "required": [ + "object", + "id", + "parent" + ], + "additionalProperties": true + }, + "database": { + "type": "object", + "description": "Notion database container object.", + "properties": { + "object": { + "type": "string", + "const": "database" + }, + "id": { + "type": "string" + }, + "parent": { + "$ref": "#/$defs/parent" + }, + "title": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "description": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "icon": { + "anyOf": [ + { + "$ref": "#/$defs/icon" + }, + { + "type": "null" + } + ] + }, + "cover": { + "anyOf": [ + { + "$ref": "#/$defs/file_ref" + }, + { + "type": "null" + } + ] + }, + "is_inline": { + "type": "boolean" + }, + "is_locked": { + "type": "boolean" + }, + "in_trash": { + "type": "boolean" + }, + "url": { + "type": "string", + "format": "uri" + }, + "data_sources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id" + ], + "additionalProperties": true + } + } + }, + "required": [ + "object", + "id", + "parent" + ], + "additionalProperties": true + }, + "comment": { + "type": "object", + "description": "Notion comment object.", + "properties": { + "object": { + "type": "string", + "const": "comment" + }, + "id": { + "type": "string" + }, + "discussion_id": { + "type": "string" + }, + "created_time": { + "type": "string", + "format": "date-time" + }, + "last_edited_time": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "created_by": { + "$ref": "#/$defs/user" + }, + "parent": { + "$ref": "#/$defs/parent" + }, + "rich_text": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "markdown": { + "type": "string" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/$defs/file_ref" + } + } + }, + "required": [ + "object", + "id", + "discussion_id", + "created_by" + ], + "additionalProperties": true + } + }, + "properties": { + "parent": { + "$ref": "#/$defs/parent", + "description": "Request body field. Parent reference controlling whether the page is standalone or a row under a data source." + }, + "properties": { + "type": "object", + "description": "Request body field. Page property map keyed by property name. Values should follow the target data source schema when the parent is a data source.", + "additionalProperties": { + "$ref": "#/$defs/property_value" + } + }, + "children": { + "type": "array", + "description": "Request body field. Initial child blocks. Prefer this structured provider-native content form over markdown when exact block structure matters.", + "items": { + "$ref": "#/$defs/block" + } + }, + "icon": { + "$ref": "#/$defs/icon", + "description": "Request body field. Optional page icon." + }, + "cover": { + "$ref": "#/$defs/file_ref", + "description": "Request body field. Optional page cover image/file reference." + }, + "template": { + "type": "object", + "description": "Request body field. Optional template application settings. Template application may be asynchronous.", + "additionalProperties": true + }, + "markdown": { + "type": "string", + "description": "Request body field. Optional provider-supported markdown body. Included because Notion supports it, but block children remain the preferred structural primitive in this package." + } + }, + "additionalProperties": false, + "required": [ + "parent", + "properties" + ], + "not": { + "required": [ + "children", + "markdown" + ] + } + } + } + ] +} diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/slack.json b/codex-rs/tools/tests/fixtures/json_schema_policy/slack.json new file mode 100644 index 000000000000..7461da73ddbf --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/slack.json @@ -0,0 +1,75 @@ +{ + "source": "standard/slack/tools", + "tools": [ + { + "name": "slack_schedule_message", + "description": "Schedule a Slack message.", + "input_schema": { + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "description": "Slack channel ID." + }, + "message": { + "type": "string", + "description": "Message text." + }, + "post_at": { + "type": "integer", + "description": "Unix timestamp for delivery." + }, + "thread_ts": { + "description": "Optional parent thread timestamp.", + "anyOf": [ + { + "$ref": "#/$defs/SlackTs" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "channel_id", + "message", + "post_at" + ], + "additionalProperties": false, + "$defs": { + "SlackTs": { + "type": "string", + "description": "Slack timestamp string.", + "pattern": "^[0-9]+[.][0-9]+$" + }, + "UnusedPayload": { + "type": "object", + "description": "Large unreachable Slack payload.", + "properties": { + "debug": { + "type": "string" + } + } + } + } + }, + "expected_preserved": [ + { + "pointer": "/properties/thread_ts/anyOf/0/$ref", + "value": "#/$defs/SlackTs" + }, + { + "pointer": "/$defs/SlackTs/description", + "value": "Slack timestamp string." + } + ], + "expected_pruned": [ + "/$defs/UnusedPayload" + ], + "expected_dropped_fields": [ + "/$defs/SlackTs/pattern" + ] + } + ] +} diff --git a/codex-rs/tools/tests/json_schema_policy_fixtures.rs b/codex-rs/tools/tests/json_schema_policy_fixtures.rs new file mode 100644 index 000000000000..1e244ade1edd --- /dev/null +++ b/codex-rs/tools/tests/json_schema_policy_fixtures.rs @@ -0,0 +1,224 @@ +use codex_tools::ToolName; +use codex_tools::mcp_tool_to_responses_api_tool; +use pretty_assertions::assert_eq; +use serde::Deserialize; +use serde::de::DeserializeOwned; +use serde_json::Value; +use serde_json::json; +use std::fs; +use std::sync::Arc; + +const FIXTURE_PATHS: [&str; 5] = [ + "tests/fixtures/json_schema_policy/slack.json", + "tests/fixtures/json_schema_policy/google_calendar.json", + "tests/fixtures/json_schema_policy/google_drive.json", + "tests/fixtures/json_schema_policy/notion.json", + "tests/fixtures/json_schema_policy/microsoft_outlook_email.json", +]; +const OVERSIZED_NOTION_CREATE_PAGE_SCHEMA_PATH: &str = + "tests/fixtures/json_schema_policy/oversized_notion_create_page_input_schema.json"; + +#[derive(Debug, Deserialize)] +struct FixtureFile { + source: String, + tools: Vec, +} + +#[derive(Debug, Deserialize)] +struct FixtureTool { + name: String, + description: String, + input_schema: Value, + #[serde(default)] + expected_preserved: Vec, + #[serde(default)] + expected_pruned: Vec, + #[serde(default)] + expected_dropped_fields: Vec, +} + +#[derive(Debug, Deserialize)] +struct ExpectedValue { + pointer: String, + value: Value, +} + +#[test] +fn json_schema_policy_fixtures_convert_to_responses_tools() { + for fixture in FIXTURE_PATHS.into_iter().map(load_fixture::) { + for fixture_tool in &fixture.tools { + let responses_tool = convert_fixture_tool(&fixture, fixture_tool); + let parameters = serde_json::to_value(&responses_tool.parameters) + .expect("responses parameters should serialize"); + + let expected_fields = [ + ( + "preserve the tool name", + json!(fixture_tool.name), + json!(responses_tool.name), + ), + ( + "preserve the tool description", + json!(fixture_tool.description), + json!(responses_tool.description), + ), + ( + "remain a strict:false tool", + json!(false), + json!(responses_tool.strict), + ), + ( + "produce object-shaped parameters", + json!("object"), + parameters.get("type").cloned().unwrap_or(Value::Null), + ), + ]; + + for (message, expected, actual) in expected_fields { + assert_eq!(actual, expected, "{} should {message}", fixture_tool.name); + } + assert!( + parameters.get("properties").is_some_and(Value::is_object), + "{} should produce a parameters.properties object", + fixture_tool.name + ); + + for expected in &fixture_tool.expected_preserved { + assert_eq!( + parameters.pointer(&expected.pointer), + Some(&expected.value), + "{} should preserve {}", + fixture_tool.name, + expected.pointer + ); + } + + for pointer in &fixture_tool.expected_pruned { + assert!( + parameters.pointer(pointer).is_none(), + "{} should prune unreachable definition {pointer}", + fixture_tool.name + ); + } + + for pointer in &fixture_tool.expected_dropped_fields { + assert!( + fixture_tool.input_schema.pointer(pointer).is_some(), + "{} fixture should contain expected dropped field {pointer}", + fixture_tool.name + ); + assert!( + parameters.pointer(pointer).is_none(), + "{} should drop field {pointer} after JsonSchema conversion", + fixture_tool.name + ); + } + } + } +} + +#[test] +fn json_schema_policy_oversized_golden_schema_triggers_compaction() { + let fixture: FixtureFile = load_fixture(OVERSIZED_NOTION_CREATE_PAGE_SCHEMA_PATH); + let fixture_tool = fixture + .tools + .first() + .expect("oversized fixture should contain a tool"); + let input_bytes = compact_json_len(&fixture_tool.input_schema); + + let responses_tool = convert_fixture_tool(&fixture, fixture_tool); + let parameters = + serde_json::to_value(&responses_tool.parameters).expect("responses parameters serialize"); + let output_bytes = compact_json_len(¶meters); + + assert!( + output_bytes < input_bytes, + "compaction should reduce schema size from {input_bytes} bytes" + ); + + let absent_pointers = [ + ("/description", "drop root description"), + ("/properties/parent/description", "drop nested descriptions"), + ( + "/$defs", + "drop root definitions after stripping descriptions is insufficient", + ), + ]; + for (pointer, message) in absent_pointers { + assert!( + parameters.pointer(pointer).is_none(), + "oversized schema should {message}" + ); + } + + let expected_values = [ + ( + "/properties/parent", + json!({}), + "rewrite local refs before dropping root definitions", + ), + ( + "/properties/children/items", + json!({}), + "rewrite nested local refs before dropping root definitions", + ), + ( + "/properties/markdown/type", + json!("string"), + "retain top-level argument shape", + ), + ( + "/properties/properties/type", + json!("object"), + "retain object argument shape", + ), + ]; + for (pointer, expected, message) in expected_values { + assert_eq!( + parameters.pointer(pointer), + Some(&expected), + "oversized schema should {message}" + ); + } +} + +fn load_fixture(path: &str) -> T { + let path = codex_utils_cargo_bin::find_resource!(path) + .unwrap_or_else(|err| panic!("resolve fixture {path}: {err}")); + let fixture = fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("read fixture {}: {err}", path.display())); + serde_json::from_str(&fixture) + .unwrap_or_else(|err| panic!("parse fixture {}: {err}", path.display())) +} + +fn convert_fixture_tool( + fixture: &FixtureFile, + fixture_tool: &FixtureTool, +) -> codex_tools::ResponsesApiTool { + let name = &fixture_tool.name; + let input_schema = fixture_tool + .input_schema + .as_object() + .unwrap_or_else(|| panic!("{name} input_schema should be an object")) + .clone(); + let tool = rmcp::model::Tool { + name: name.to_string().into(), + title: None, + description: Some(fixture_tool.description.clone().into()), + input_schema: Arc::new(input_schema), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }; + + mcp_tool_to_responses_api_tool(&ToolName::namespaced(&fixture.source, name), &tool) + .unwrap_or_else(|err| panic!("convert {name} from {}: {err}", fixture.source)) +} + +fn compact_json_len(value: &Value) -> usize { + serde_json::to_vec(value) + .unwrap_or_else(|err| panic!("serialize compact JSON: {err}")) + .len() +} diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index c213e92bae41..40624baba0d0 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -126,7 +126,7 @@ uuid = { workspace = true } codex-windows-sandbox = { workspace = true } tokio-util = { workspace = true, features = ["time"] } -[target.'cfg(not(target_os = "linux"))'.dependencies] +[target.'cfg(all(not(target_os = "linux"), not(target_os = "android")))'.dependencies] cpal = "0.15" [target.'cfg(unix)'.dependencies] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8d1797e72478..3c3d15086f85 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -50,7 +50,6 @@ use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::PermissionProfileSnapshot; -use crate::legacy_core::config::edit::ConfigEdit; use crate::legacy_core::config::edit::ConfigEditsBuilder; #[cfg(target_os = "windows")] use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; @@ -91,6 +90,7 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigLayerSource; +use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::FeedbackUploadParams; @@ -113,6 +113,7 @@ use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RateLimitSnapshot; +use codex_app_server_protocol::SandboxMode as AppServerSandboxMode; use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; @@ -127,12 +128,17 @@ use codex_app_server_protocol::ThreadStartSource; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::WriteStatus; use codex_config::ConfigLayerStackOrdering; use codex_config::LoaderOverrides; use codex_config::types::ApprovalsReviewer; +use codex_config::types::MemoriesToml; use codex_config::types::ModelAvailabilityNuxConfig; +#[cfg(target_os = "windows")] +use codex_config::types::WindowsToml; use codex_exec_server::EnvironmentManager; use codex_features::Feature; +use codex_features::FeaturesToml; use codex_model_provider::create_model_provider; use codex_model_provider_info::ModelProviderInfo; use codex_models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; @@ -480,7 +486,6 @@ pub(crate) struct App { /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, pub(crate) state_db: Option, - pub(crate) active_profile: Option, cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, loader_overrides: LoaderOverrides, @@ -687,7 +692,6 @@ impl App { cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, loader_overrides: LoaderOverrides, - active_profile: Option, initial_prompt: Option, initial_images: Vec, session_selection: SessionSelection, @@ -965,7 +969,6 @@ See the Codex keymap documentation for supported actions and examples." workspace_command_runner: Some(workspace_command_runner), config, state_db, - active_profile, cli_kv_overrides, harness_overrides, loader_overrides, diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index d4fdebad89df..e83662c2a25c 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -198,6 +198,28 @@ impl App { } } + pub(super) async fn read_effective_config_after_overridden_write( + &mut self, + app_server: &mut AppServerSession, + setting: &str, + ) -> Option { + let cwd = self.chat_widget.config_ref().cwd.display().to_string(); + match crate::config_update::read_effective_config(app_server.request_handle(), cwd).await { + Ok(response) => Some(response), + Err(err) => { + tracing::warn!( + error = %err, + setting, + "failed to refresh effective config after an overridden write" + ); + self.chat_widget.add_error_message(format!( + "{setting} were saved, but Codex could not refresh the effective config: {err}" + )); + None + } + } + } + pub(super) async fn rebuild_config_for_resume_or_fallback( &mut self, current_cwd: &Path, @@ -311,21 +333,17 @@ impl App { Some(permission_profile) } - pub(super) async fn update_feature_flags(&mut self, updates: Vec<(Feature, bool)>) { + pub(super) async fn update_feature_flags( + &mut self, + app_server: &mut AppServerSession, + updates: Vec<(Feature, bool)>, + ) { if updates.is_empty() { return; } let auto_review_preset = auto_review_mode(); let mut next_config = self.config.clone(); - let active_profile = self.active_profile.clone(); - let scoped_segments = |key: &str| { - if let Some(profile) = active_profile.as_deref() { - vec!["profiles".to_string(), profile.to_string(), key.to_string()] - } else { - vec![key.to_string()] - } - }; let windows_sandbox_changed = updates.iter().any(|(feature, _)| { matches!( feature, @@ -337,43 +355,12 @@ impl App { let mut permission_profile_override = None; let mut active_permission_profile_override = None; let mut feature_updates_to_apply = Vec::with_capacity(updates.len()); - // Auto-Review owns `approvals_reviewer`, but disabling the feature - // from inside a profile should not silently clear a value configured at - // the root scope. - let (root_approvals_reviewer_blocks_profile_disable, profile_approvals_reviewer_configured) = { - let effective_config = next_config.config_layer_stack.effective_config(); - let root_blocks_disable = effective_config - .as_table() - .and_then(|table| table.get("approvals_reviewer")) - .is_some_and(|value| value != &TomlValue::String("user".to_string())); - let profile_configured = active_profile.as_deref().is_some_and(|profile| { - effective_config - .as_table() - .and_then(|table| table.get("profiles")) - .and_then(TomlValue::as_table) - .and_then(|profiles| profiles.get(profile)) - .and_then(TomlValue::as_table) - .is_some_and(|profile_config| profile_config.contains_key("approvals_reviewer")) - }); - (root_blocks_disable, profile_configured) - }; let mut permissions_history_label: Option<&'static str> = None; - let mut builder = ConfigEditsBuilder::for_config(&self.config) - .with_profile(self.active_profile.as_deref()); + let mut config_edits = Vec::new(); for (feature, enabled) in updates { let feature_key = feature.key(); let mut feature_edits = Vec::new(); - if feature == Feature::GuardianApproval - && !enabled - && self.active_profile.is_some() - && root_approvals_reviewer_blocks_profile_disable - { - self.chat_widget.add_error_message( - "Cannot disable Auto-review in this profile because `approvals_reviewer` is configured outside the active profile.".to_string(), - ); - continue; - } let mut feature_config = next_config.clone(); if let Err(err) = feature_config.features.set_enabled(feature, enabled) { tracing::error!( @@ -394,19 +381,17 @@ impl App { // experiment's matching `/permissions` mode until the user // changes it explicitly. feature_config.approvals_reviewer = auto_review_preset.approvals_reviewer; - feature_edits.push(ConfigEdit::SetPath { - segments: scoped_segments("approvals_reviewer"), - value: auto_review_preset.approvals_reviewer.to_string().into(), - }); + feature_edits.push(crate::config_update::replace_config_value( + "approvals_reviewer", + serde_json::json!(auto_review_preset.approvals_reviewer.to_string()), + )); if previous_approvals_reviewer != auto_review_preset.approvals_reviewer { permissions_history_label = Some("Auto-review"); } } else if !effective_enabled { - if profile_approvals_reviewer_configured || self.active_profile.is_none() { - feature_edits.push(ConfigEdit::ClearPath { - segments: scoped_segments("approvals_reviewer"), - }); - } + feature_edits.push(crate::config_update::clear_config_value( + "approvals_reviewer", + )); feature_config.approvals_reviewer = ApprovalsReviewer::User; if previous_approvals_reviewer != ApprovalsReviewer::User { permissions_history_label = Some("Default"); @@ -438,14 +423,14 @@ impl App { continue; }; feature_edits.extend([ - ConfigEdit::SetPath { - segments: scoped_segments("approval_policy"), - value: "on-request".into(), - }, - ConfigEdit::SetPath { - segments: scoped_segments("sandbox_mode"), - value: "workspace-write".into(), - }, + crate::config_update::replace_config_value( + "approval_policy", + serde_json::json!("on-request"), + ), + crate::config_update::replace_config_value( + "sandbox_mode", + serde_json::json!("workspace-write"), + ), ]); approval_policy_override = Some(auto_review_preset.approval_policy); permission_profile_override = Some(permission_profile); @@ -454,18 +439,59 @@ impl App { } next_config = feature_config; feature_updates_to_apply.push((feature, effective_enabled)); - builder = builder - .with_edits(feature_edits) - .set_feature_enabled(feature_key, effective_enabled); + config_edits.extend(feature_edits); + config_edits.push(crate::config_update::build_feature_enabled_edit( + feature_key, + effective_enabled, + )); } // Persist first so the live session does not diverge from disk if the // config edit fails. Runtime/UI state is patched below only after the // durable config update succeeds. - if let Err(err) = builder.apply().await { - tracing::error!(error = %err, "failed to persist feature flags"); - self.chat_widget - .add_error_message(format!("Failed to update experimental features: {err}")); + let write_response = match crate::config_update::write_config_batch( + app_server.request_handle(), + config_edits, + ) + .await + { + Ok(response) => response, + Err(err) => { + tracing::error!(error = %err, "failed to persist feature flags"); + self.chat_widget + .add_error_message(format!("Failed to update experimental features: {err}")); + return; + } + }; + if write_response.status == WriteStatus::OkOverridden { + let message = overridden_write_message(&write_response); + tracing::warn!( + message, + "feature flag config write was overridden by effective config" + ); + self.chat_widget.add_error_message(format!( + "Experimental feature changes were saved but not applied: {message}" + )); + if let Some(effective_config) = self + .read_effective_config_after_overridden_write( + app_server, + "Experimental feature changes", + ) + .await + { + self.sync_feature_state_from_effective_config( + &effective_config, + &feature_updates_to_apply, + ); + self.sync_auto_review_runtime_state_from_effective_config( + &effective_config, + &feature_updates_to_apply, + ) + .await; + if windows_sandbox_changed { + self.propagate_windows_sandbox_turn_context(); + } + } return; } @@ -550,26 +576,7 @@ impl App { } if windows_sandbox_changed { - #[cfg(target_os = "windows")] - { - let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); - self.app_event_tx - .send(AppEvent::CodexOp(AppCommand::override_turn_context( - /*cwd*/ None, - /*approval_policy*/ None, - /*approvals_reviewer*/ None, - /*permission_profile*/ None, - /*active_permission_profile*/ None, - #[cfg(target_os = "windows")] - Some(windows_sandbox_level), - /*model*/ None, - /*effort*/ None, - /*summary*/ None, - /*service_tier*/ None, - /*collaboration_mode*/ None, - /*personality*/ None, - ))); - } + self.propagate_windows_sandbox_turn_context(); } if let Some(label) = permissions_history_label { @@ -582,42 +589,43 @@ impl App { pub(super) async fn update_memory_settings( &mut self, + app_server: &mut AppServerSession, use_memories: bool, generate_memories: bool, ) -> bool { - let active_profile = self.active_profile.clone(); - let scoped_memory_segments = |key: &str| { - if let Some(profile) = active_profile.as_deref() { - vec![ - "profiles".to_string(), - profile.to_string(), - "memories".to_string(), - key.to_string(), - ] - } else { - vec!["memories".to_string(), key.to_string()] + let edits = + crate::config_update::build_memory_settings_edits(use_memories, generate_memories); + + let write_response = match crate::config_update::write_config_batch( + app_server.request_handle(), + edits, + ) + .await + { + Ok(response) => response, + Err(err) => { + tracing::error!(error = %err, "failed to persist memory settings"); + self.chat_widget + .add_error_message(format!("Failed to save memory settings: {err}")); + return false; } }; - let edits = [ - ConfigEdit::SetPath { - segments: scoped_memory_segments("use_memories"), - value: use_memories.into(), - }, - ConfigEdit::SetPath { - segments: scoped_memory_segments("generate_memories"), - value: generate_memories.into(), - }, - ]; - - if let Err(err) = ConfigEditsBuilder::for_config(&self.config) - .with_edits(edits) - .apply() - .await - { - tracing::error!(error = %err, "failed to persist memory settings"); - self.chat_widget - .add_error_message(format!("Failed to save memory settings: {err}")); - return false; + if write_response.status == WriteStatus::OkOverridden { + let message = overridden_write_message(&write_response); + tracing::warn!( + message, + "memory settings config write was overridden by effective config" + ); + self.chat_widget.add_error_message(format!( + "Memory setting changes were saved but not applied: {message}" + )); + let Some(effective_config) = self + .read_effective_config_after_overridden_write(app_server, "Memory setting changes") + .await + else { + return false; + }; + return self.sync_memory_state_from_effective_config(&effective_config); } self.config.memories.use_memories = use_memories; @@ -635,12 +643,13 @@ impl App { ) { let previous_generate_memories = self.config.memories.generate_memories; if !self - .update_memory_settings(use_memories, generate_memories) + .update_memory_settings(app_server, use_memories, generate_memories) .await { return; } + let generate_memories = self.config.memories.generate_memories; if previous_generate_memories == generate_memories { return; } @@ -754,6 +763,274 @@ impl App { Personality::Pragmatic => "Pragmatic", } } + + fn sync_feature_state_from_effective_config( + &mut self, + effective_config: &ConfigReadResponse, + feature_updates: &[(Feature, bool)], + ) { + for (feature, _) in feature_updates { + let enabled = feature_enabled_from_effective_config(effective_config, *feature); + if let Err(err) = self.config.features.set_enabled(*feature, enabled) { + tracing::warn!( + error = %err, + feature = feature.key(), + "failed to sync effective feature state after an overridden write" + ); + continue; + } + self.chat_widget.set_feature_enabled(*feature, enabled); + } + + if feature_updates + .iter() + .any(|(feature, _)| *feature == Feature::GuardianApproval) + && !self.config.features.enabled(Feature::GuardianApproval) + { + self.set_approvals_reviewer_in_app_and_widget(ApprovalsReviewer::User); + return; + } + + if let Some(reviewer) = approvals_reviewer_from_effective_config(effective_config) { + self.set_approvals_reviewer_in_app_and_widget(reviewer); + } + if let Some(policy) = approval_policy_from_effective_config(effective_config) { + if let Err(err) = self + .config + .permissions + .approval_policy + .set(policy.to_core()) + { + tracing::warn!( + error = %err, + "failed to sync effective approval policy after an overridden write" + ); + self.chat_widget.add_error_message(format!( + "Failed to refresh overridden Auto-review settings: {err}" + )); + } else { + self.chat_widget.set_approval_policy(policy); + } + } + } + + async fn sync_auto_review_runtime_state_from_effective_config( + &mut self, + effective_config: &ConfigReadResponse, + feature_updates: &[(Feature, bool)], + ) { + if !feature_updates + .iter() + .any(|(feature, _)| *feature == Feature::GuardianApproval) + || !self.config.features.enabled(Feature::GuardianApproval) + || sandbox_mode_from_effective_config(effective_config) + != Some(AppServerSandboxMode::WorkspaceWrite) + { + return; + } + + let auto_review_preset = auto_review_mode(); + let mut config = self.config.clone(); + let Some(permission_profile) = self.try_set_builtin_active_permission_profile_on_config( + &mut config, + auto_review_preset.active_permission_profile.clone(), + "Failed to refresh overridden Auto-review settings", + "failed to sync overridden Auto-review permission profile", + ) else { + return; + }; + self.config = config; + if let Err(err) = self + .chat_widget + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + permission_profile.clone(), + auto_review_preset.active_permission_profile.clone(), + )) + { + tracing::warn!( + error = %err, + "failed to sync overridden Auto-review permission profile on chat config" + ); + self.chat_widget.add_error_message(format!( + "Failed to refresh overridden Auto-review settings: {err}" + )); + return; + } + + self.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&self.config)); + self.sync_active_thread_permission_settings_to_cached_session() + .await; + + let approval_policy = AskForApproval::from(self.config.permissions.approval_policy.value()); + let op = AppCommand::override_turn_context( + /*cwd*/ None, + Some(approval_policy), + Some(self.config.approvals_reviewer), + /*permission_profile*/ None, + Some(auto_review_preset.active_permission_profile), + /*windows_sandbox_level*/ None, + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, + ); + let replay_state_op = + ThreadEventStore::op_can_change_pending_replay_state(&op).then(|| op.clone()); + let submitted = self.chat_widget.submit_op(op); + if submitted && let Some(op) = replay_state_op.as_ref() { + self.note_active_thread_outbound_op(op).await; + self.refresh_pending_thread_approvals().await; + } + } + + fn sync_memory_state_from_effective_config( + &mut self, + effective_config: &ConfigReadResponse, + ) -> bool { + let Some(memories) = memories_from_effective_config(effective_config) else { + tracing::warn!( + "config/read omitted memories after an overridden memory settings write" + ); + return false; + }; + let use_memories = memories + .use_memories + .unwrap_or(self.config.memories.use_memories); + let generate_memories = memories + .generate_memories + .unwrap_or(self.config.memories.generate_memories); + self.config.memories.use_memories = use_memories; + self.config.memories.generate_memories = generate_memories; + self.chat_widget + .set_memory_settings(use_memories, generate_memories); + true + } + + #[cfg(target_os = "windows")] + pub(super) async fn sync_windows_sandbox_after_overridden_write( + &mut self, + app_server: &mut AppServerSession, + write_response: &ConfigWriteResponse, + ) { + let message = overridden_write_message(write_response); + tracing::warn!( + message, + "Windows sandbox config write was overridden by effective config" + ); + self.chat_widget.add_error_message(format!( + "Windows sandbox changes were saved but not applied: {message}" + )); + let Some(effective_config) = self + .read_effective_config_after_overridden_write(app_server, "Windows sandbox changes") + .await + else { + return; + }; + let Some(mode) = windows_sandbox_mode_from_effective_config(&effective_config) else { + return; + }; + self.config.permissions.windows_sandbox_mode = Some(mode); + self.chat_widget.set_windows_sandbox_mode(Some(mode)); + self.propagate_windows_sandbox_turn_context(); + } + + fn propagate_windows_sandbox_turn_context(&self) { + #[cfg(target_os = "windows")] + { + let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); + self.app_event_tx + .send(AppEvent::CodexOp(AppCommand::override_turn_context( + /*cwd*/ None, + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + /*permission_profile*/ None, + /*active_permission_profile*/ None, + Some(windows_sandbox_level), + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, + ))); + } + } +} + +fn overridden_write_message(write_response: &ConfigWriteResponse) -> &str { + write_response + .overridden_metadata + .as_ref() + .map(|metadata| metadata.message.as_str()) + .unwrap_or("the effective config is overridden by a higher-priority layer") +} + +fn feature_enabled_from_effective_config( + effective_config: &ConfigReadResponse, + feature: Feature, +) -> bool { + let root_features = effective_config + .config + .additional + .get("features") + .and_then(features_toml_from_json); + root_features + .as_ref() + .and_then(|features| features.entries().get(feature.key()).copied()) + .unwrap_or_else(|| feature.default_enabled()) +} + +fn approvals_reviewer_from_effective_config( + effective_config: &ConfigReadResponse, +) -> Option { + effective_config + .config + .approvals_reviewer + .map(codex_app_server_protocol::ApprovalsReviewer::to_core) +} + +fn approval_policy_from_effective_config( + effective_config: &ConfigReadResponse, +) -> Option { + effective_config.config.approval_policy +} + +fn sandbox_mode_from_effective_config( + effective_config: &ConfigReadResponse, +) -> Option { + effective_config.config.sandbox_mode +} + +fn memories_from_effective_config(effective_config: &ConfigReadResponse) -> Option { + effective_config + .config + .additional + .get("memories") + .and_then(|memories| serde_json::from_value(memories.clone()).ok()) +} + +fn features_toml_from_json(value: &serde_json::Value) -> Option { + serde_json::from_value(value.clone()).ok() +} + +#[cfg(target_os = "windows")] +fn windows_sandbox_mode_from_effective_config( + effective_config: &ConfigReadResponse, +) -> Option { + let root_windows = effective_config + .config + .additional + .get("windows") + .and_then(windows_toml_from_json); + root_windows.and_then(|windows| windows.sandbox) +} + +#[cfg(target_os = "windows")] +fn windows_toml_from_json(value: &serde_json::Value) -> Option { + serde_json::from_value(value.clone()).ok() } #[cfg(test)] @@ -902,6 +1179,46 @@ terminal_resize_reflow_max_rows = 9000 Ok(()) } + #[tokio::test] + async fn overridden_disabled_guardian_does_not_apply_auto_review_companions() -> Result<()> { + let mut app = make_test_app().await; + let original_policy = app.config.permissions.approval_policy.value(); + let effective_config: ConfigReadResponse = serde_json::from_value(serde_json::json!({ + "config": { + "approval_policy": AskForApproval::OnRequest, + "approvals_reviewer": codex_app_server_protocol::ApprovalsReviewer::AutoReview, + "sandbox_mode": AppServerSandboxMode::WorkspaceWrite, + "features": { + "guardian_approval": false, + }, + }, + "origins": {}, + }))?; + + app.sync_feature_state_from_effective_config( + &effective_config, + &[(Feature::GuardianApproval, /*enabled*/ true)], + ); + + assert!(!app.config.features.enabled(Feature::GuardianApproval)); + assert!( + !app.chat_widget + .config_ref() + .features + .enabled(Feature::GuardianApproval) + ); + assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::User); + assert_eq!( + app.chat_widget.config_ref().approvals_reviewer, + ApprovalsReviewer::User + ); + assert_eq!( + app.config.permissions.approval_policy.value(), + original_policy + ); + Ok(()) + } + #[tokio::test] async fn rebuild_config_for_resume_or_fallback_uses_current_config_on_same_cwd_error() -> Result<()> { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ccc538b3d534..e150dbccd21b 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1158,18 +1158,20 @@ impl App { &[("result", "success")], ); } - let profile = self.active_profile.as_deref(); let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated); - let builder = ConfigEditsBuilder::for_config(&self.config) - .with_profile(profile) - .set_windows_sandbox_mode(if elevated_enabled { - "elevated" - } else { - "unelevated" - }) - .clear_legacy_windows_sandbox_keys(); - match builder.apply().await { - Ok(()) => { + let edits = + crate::config_update::build_windows_sandbox_mode_edits(elevated_enabled); + match crate::config_update::write_config_batch( + app_server.request_handle(), + edits, + ) + .await + { + Ok(response) if response.status == WriteStatus::OkOverridden => { + self.sync_windows_sandbox_after_overridden_write(app_server, &response) + .await; + } + Ok(_) => { if elevated_enabled { self.config.set_windows_sandbox_enabled(/*value*/ false); self.config @@ -1294,18 +1296,13 @@ impl App { } } AppEvent::PersistModelSelection { model, effort } => { - let profile = self.active_profile.as_deref(); match crate::config_update::write_config_batch( app_server.request_handle(), - crate::config_update::build_model_selection_edits( - profile, - model.as_str(), - effort, - ), + crate::config_update::build_model_selection_edits(model.as_str(), effort), ) .await { - Ok(()) => { + Ok(_) => { let effort_label = effort .map(|selected_effort| selected_effort.to_string()) .unwrap_or_else(|| "default".to_string()); @@ -1315,11 +1312,6 @@ impl App { message.push(' '); message.push_str(label); } - if let Some(profile) = profile { - message.push_str(" for "); - message.push_str(profile); - message.push_str(" profile"); - } self.chat_widget.add_info_message(message, /*hint*/ None); } Err(err) => { @@ -1327,14 +1319,8 @@ impl App { error = %err, "failed to persist model selection" ); - if let Some(profile) = profile { - self.chat_widget.add_error_message(format!( - "Failed to save model for profile `{profile}`: {err}" - )); - } else { - self.chat_widget - .add_error_message(format!("Failed to save default model: {err}")); - } + self.chat_widget + .add_error_message(format!("Failed to save default model: {err}")); } } } @@ -1376,24 +1362,18 @@ impl App { self.chat_widget.on_plugin_mentions_loaded(plugins); } AppEvent::PersistPersonalitySelection { personality } => { - let profile = self.active_profile.as_deref(); match crate::config_update::write_config_batch( app_server.request_handle(), vec![crate::config_update::replace_config_value( - crate::config_update::profile_scoped_key_path(profile, "personality"), + "personality", serde_json::json!(personality.to_string()), )], ) .await { - Ok(()) => { + Ok(_) => { let label = Self::personality_label(personality); - let mut message = format!("Personality set to {label}"); - if let Some(profile) = profile { - message.push_str(" for "); - message.push_str(profile); - message.push_str(" profile"); - } + let message = format!("Personality set to {label}"); self.chat_widget.add_info_message(message, /*hint*/ None); } Err(err) => { @@ -1401,15 +1381,9 @@ impl App { error = %err, "failed to persist personality selection" ); - if let Some(profile) = profile { - self.chat_widget.add_error_message(format!( - "Failed to save personality for profile `{profile}`: {err}" - )); - } else { - self.chat_widget.add_error_message(format!( - "Failed to save default personality: {err}" - )); - } + self.chat_widget.add_error_message(format!( + "Failed to save default personality: {err}" + )); } } } @@ -1418,38 +1392,25 @@ impl App { self.config.service_tier = service_tier.clone(); self.sync_active_thread_service_tier_to_cached_session() .await; - let profile = self.active_profile.as_deref(); let edits = crate::config_update::build_service_tier_selection_edits( - profile, service_tier.as_deref(), ); match crate::config_update::write_config_batch(app_server.request_handle(), edits) .await { - Ok(()) => { - let mut message = if let Some(service_tier) = service_tier { + Ok(_) => { + let message = if let Some(service_tier) = service_tier { format!("Service tier set to {service_tier}") } else { "Service tier cleared".to_string() }; - if let Some(profile) = profile { - message.push_str(" for "); - message.push_str(profile); - message.push_str(" profile"); - } self.chat_widget.add_info_message(message, /*hint*/ None); } Err(err) => { tracing::error!(error = %err, "failed to persist service tier selection"); - if let Some(profile) = profile { - self.chat_widget.add_error_message(format!( - "Failed to save service tier for profile `{profile}`: {err}" - )); - } else { - self.chat_widget.add_error_message(format!( - "Failed to save default service tier: {err}" - )); - } + self.chat_widget.add_error_message(format!( + "Failed to save default service tier: {err}" + )); } } } @@ -1598,14 +1559,10 @@ impl App { self.chat_widget.set_approvals_reviewer(policy); self.sync_active_thread_permission_settings_to_cached_session() .await; - let profile = self.active_profile.as_deref(); if let Err(err) = crate::config_update::write_config_batch( app_server.request_handle(), vec![crate::config_update::replace_config_value( - crate::config_update::profile_scoped_key_path( - profile, - "approvals_reviewer", - ), + "approvals_reviewer", serde_json::json!(policy.to_string()), )], ) @@ -1620,7 +1577,7 @@ impl App { } } AppEvent::UpdateFeatureFlags { updates } => { - self.update_feature_flags(updates).await; + self.update_feature_flags(app_server, updates).await; } AppEvent::UpdateMemorySettings { use_memories, @@ -1701,11 +1658,7 @@ impl App { } } AppEvent::PersistPlanModeReasoningEffort(effort) => { - let profile = self.active_profile.as_deref(); - let key_path = crate::config_update::profile_scoped_key_path( - profile, - "plan_mode_reasoning_effort", - ); + let key_path = "plan_mode_reasoning_effort"; let edit = if let Some(effort) = effort { crate::config_update::replace_config_value( key_path, @@ -1724,15 +1677,9 @@ impl App { error = %err, "failed to persist plan mode reasoning effort" ); - if let Some(profile) = profile { - self.chat_widget.add_error_message(format!( - "Failed to save Plan mode reasoning effort for profile `{profile}`: {err}" - )); - } else { - self.chat_widget.add_error_message(format!( - "Failed to save Plan mode reasoning effort: {err}" - )); - } + self.chat_widget.add_error_message(format!( + "Failed to save Plan mode reasoning effort: {err}" + )); } } AppEvent::PersistModelMigrationPromptAcknowledged { diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 10c234a903a9..9bbe0c60b478 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -22,7 +22,6 @@ pub(super) async fn make_test_app() -> App { workspace_command_runner: None, config, state_db: None, - active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), loader_overrides: LoaderOverrides::without_managed_config_for_tests(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index c0050db28582..04c8bbf9a060 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1719,8 +1719,9 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); let auto_review = auto_review_mode(); + let mut app_server = start_config_write_test_app_server(&app).await?; - app.update_feature_flags(vec![(Feature::GuardianApproval, true)]) + app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, true)]) .await; assert!(app.config.features.enabled(Feature::GuardianApproval)); @@ -1809,6 +1810,7 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< assert!(config.contains("approvals_reviewer = \"guardian_subagent\"")); assert!(config.contains("approval_policy = \"on-request\"")); assert!(config.contains("sandbox_mode = \"workspace-write\"")); + app_server.shutdown().await?; Ok(()) } @@ -1847,8 +1849,9 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy( PermissionProfile::workspace_write(), ))?; + let mut app_server = start_config_write_test_app_server(&app).await?; - app.update_feature_flags(vec![(Feature::GuardianApproval, false)]) + app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, false)]) .await; assert!(!app.config.features.enabled(Feature::GuardianApproval)); @@ -1902,6 +1905,7 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor assert!(!config.contains("approvals_reviewer =")); assert!(config.contains("approval_policy = \"on-request\"")); assert!(config.contains("sandbox_mode = \"workspace-write\"")); + app_server.shutdown().await?; Ok(()) } @@ -1923,8 +1927,9 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review app.config.approvals_reviewer = ApprovalsReviewer::User; app.chat_widget .set_approvals_reviewer(ApprovalsReviewer::User); + let mut app_server = start_config_write_test_app_server(&app).await?; - app.update_feature_flags(vec![(Feature::GuardianApproval, true)]) + app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, true)]) .await; assert!(app.config.features.enabled(Feature::GuardianApproval)); @@ -1970,6 +1975,7 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review assert!(config.contains("guardian_approval = true")); assert!(config.contains("approval_policy = \"on-request\"")); assert!(config.contains("sandbox_mode = \"workspace-write\"")); + app_server.shutdown().await?; Ok(()) } @@ -1995,8 +2001,9 @@ async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_wit app.config.approvals_reviewer = ApprovalsReviewer::User; app.chat_widget .set_approvals_reviewer(ApprovalsReviewer::User); + let mut app_server = start_config_write_test_app_server(&app).await?; - app.update_feature_flags(vec![(Feature::GuardianApproval, false)]) + app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, false)]) .await; assert!(!app.config.features.enabled(Feature::GuardianApproval)); @@ -2030,233 +2037,7 @@ async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_wit let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; assert!(!config.contains("guardian_approval = true")); assert!(!config.contains("approvals_reviewer =")); - Ok(()) -} - -#[tokio::test] -async fn update_feature_flags_enabling_guardian_in_profile_sets_profile_auto_review_policy() --> Result<()> { - let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; - let codex_home = tempdir()?; - app.config.codex_home = codex_home.path().to_path_buf().abs(); - let auto_review = auto_review_mode(); - app.active_profile = Some("guardian".to_string()); - let config_toml_path = codex_home.path().join("config.toml").abs(); - let config_toml = "profile = \"guardian\"\napprovals_reviewer = \"user\"\n"; - std::fs::write(config_toml_path.as_path(), config_toml)?; - let user_config = toml::from_str::(config_toml)?; - app.config.config_layer_stack = app - .config - .config_layer_stack - .with_user_config(&config_toml_path, user_config); - app.config.approvals_reviewer = ApprovalsReviewer::User; - app.chat_widget - .set_approvals_reviewer(ApprovalsReviewer::User); - - app.update_feature_flags(vec![(Feature::GuardianApproval, true)]) - .await; - - assert!(app.config.features.enabled(Feature::GuardianApproval)); - assert_eq!( - app.config.approvals_reviewer, - auto_review.approvals_reviewer - ); - assert_eq!( - app.chat_widget.config_ref().approvals_reviewer, - auto_review.approvals_reviewer - ); - assert_eq!( - op_rx.try_recv(), - Ok(Op::OverrideTurnContext { - cwd: None, - approval_policy: Some(auto_review.approval_policy), - approvals_reviewer: Some(auto_review.approvals_reviewer), - permission_profile: Some(auto_review.permission_profile()), - active_permission_profile: Some(auto_review.active_permission_profile.clone()), - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - ); - - let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - let config_value = toml::from_str::(&config)?; - let profile_config = config_value - .as_table() - .and_then(|table| table.get("profiles")) - .and_then(TomlValue::as_table) - .and_then(|profiles| profiles.get("guardian")) - .and_then(TomlValue::as_table) - .expect("guardian profile should exist"); - assert_eq!( - config_value - .as_table() - .and_then(|table| table.get("approvals_reviewer")), - Some(&TomlValue::String("user".to_string())) - ); - assert_eq!( - profile_config.get("approvals_reviewer"), - Some(&TomlValue::String("guardian_subagent".to_string())) - ); - Ok(()) -} - -#[tokio::test] -async fn update_feature_flags_disabling_guardian_in_profile_allows_inherited_user_reviewer() --> Result<()> { - let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; - let codex_home = tempdir()?; - app.config.codex_home = codex_home.path().to_path_buf().abs(); - app.active_profile = Some("guardian".to_string()); - let config_toml_path = codex_home.path().join("config.toml").abs(); - let config_toml = r#" -profile = "guardian" -approvals_reviewer = "user" - -[profiles.guardian] -approvals_reviewer = "guardian_subagent" - -[profiles.guardian.features] -guardian_approval = true -"#; - std::fs::write(config_toml_path.as_path(), config_toml)?; - let user_config = toml::from_str::(config_toml)?; - app.config.config_layer_stack = app - .config - .config_layer_stack - .with_user_config(&config_toml_path, user_config); - app.config - .features - .set_enabled(Feature::GuardianApproval, /*enabled*/ true)?; - app.chat_widget - .set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true); - app.config.approvals_reviewer = ApprovalsReviewer::AutoReview; - app.chat_widget - .set_approvals_reviewer(ApprovalsReviewer::AutoReview); - - app.update_feature_flags(vec![(Feature::GuardianApproval, false)]) - .await; - - assert!(!app.config.features.enabled(Feature::GuardianApproval)); - assert!( - !app.chat_widget - .config_ref() - .features - .enabled(Feature::GuardianApproval) - ); - assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::User); - assert_eq!( - app.chat_widget.config_ref().approvals_reviewer, - ApprovalsReviewer::User - ); - assert_eq!( - op_rx.try_recv(), - Ok(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: Some(ApprovalsReviewer::User), - permission_profile: None, - active_permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - ); - let cell = match app_event_rx.try_recv() { - Ok(AppEvent::InsertHistoryCell(cell)) => cell, - other => panic!("expected InsertHistoryCell event, got {other:?}"), - }; - let rendered = cell - .display_lines(/*width*/ 120) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"); - assert!(rendered.contains("Permissions updated to Default")); - - let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(!config.contains("guardian_approval = true")); - assert!(!config.contains("guardian_subagent")); - assert_eq!( - toml::from_str::(&config)? - .as_table() - .and_then(|table| table.get("approvals_reviewer")), - Some(&TomlValue::String("user".to_string())) - ); - Ok(()) -} - -#[tokio::test] -async fn update_feature_flags_disabling_guardian_in_profile_keeps_inherited_non_user_reviewer_enabled() --> Result<()> { - let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; - let codex_home = tempdir()?; - app.config.codex_home = codex_home.path().to_path_buf().abs(); - app.active_profile = Some("guardian".to_string()); - let config_toml_path = codex_home.path().join("config.toml").abs(); - let config_toml = "profile = \"guardian\"\napprovals_reviewer = \"guardian_subagent\"\n\n[features]\nguardian_approval = true\n"; - std::fs::write(config_toml_path.as_path(), config_toml)?; - let user_config = toml::from_str::(config_toml)?; - app.config.config_layer_stack = app - .config - .config_layer_stack - .with_user_config(&config_toml_path, user_config); - app.config - .features - .set_enabled(Feature::GuardianApproval, /*enabled*/ true)?; - app.chat_widget - .set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true); - app.config.approvals_reviewer = ApprovalsReviewer::AutoReview; - app.chat_widget - .set_approvals_reviewer(ApprovalsReviewer::AutoReview); - - app.update_feature_flags(vec![(Feature::GuardianApproval, false)]) - .await; - - assert!(app.config.features.enabled(Feature::GuardianApproval)); - assert!( - app.chat_widget - .config_ref() - .features - .enabled(Feature::GuardianApproval) - ); - assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::AutoReview); - assert_eq!( - app.chat_widget.config_ref().approvals_reviewer, - ApprovalsReviewer::AutoReview - ); - assert!( - op_rx.try_recv().is_err(), - "disabling an inherited non-user reviewer should not patch the active session" - ); - let app_events = std::iter::from_fn(|| app_event_rx.try_recv().ok()).collect::>(); - assert!( - !app_events.iter().any(|event| match event { - AppEvent::InsertHistoryCell(cell) => cell - .display_lines(/*width*/ 120) - .iter() - .any(|line| line.to_string().contains("Permissions updated to")), - _ => false, - }), - "blocking disable with inherited guardian review should not emit a permissions history update: {app_events:?}" - ); - - let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(config.contains("guardian_approval = true")); - assert_eq!( - toml::from_str::(&config)? - .as_table() - .and_then(|table| table.get("approvals_reviewer")), - Some(&TomlValue::String("guardian_subagent".to_string())) - ); + app_server.shutdown().await?; Ok(()) } @@ -3965,7 +3746,6 @@ async fn make_test_app() -> App { workspace_command_runner: None, config, state_db: None, - active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), loader_overrides: LoaderOverrides::without_managed_config_for_tests(), @@ -4029,7 +3809,6 @@ async fn make_test_app_with_channels() -> ( workspace_command_runner: None, config, state_db: None, - active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), loader_overrides: LoaderOverrides::without_managed_config_for_tests(), @@ -5758,3 +5537,6 @@ async fn side_backtrack_rejection_reports_unavailable_message_snapshot() { rendered ); } +async fn start_config_write_test_app_server(app: &App) -> Result { + Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await +} diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 4597c6091388..59d56ca56eb1 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1224,7 +1224,6 @@ fn config_request_overrides_from_config( overrides.insert(key.to_string(), serde_json::Value::String(value)); } }; - insert("profile", config.active_profile.clone()); insert( "model_reasoning_effort", config diff --git a/codex-rs/tui/src/config_update.rs b/codex-rs/tui/src/config_update.rs index f3dc0dc5fc27..9d73bb1a2adb 100644 --- a/codex-rs/tui/src/config_update.rs +++ b/codex-rs/tui/src/config_update.rs @@ -8,11 +8,14 @@ use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigEdit; +use codex_app_server_protocol::ConfigReadParams; +use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SkillsConfigWriteParams; use codex_app_server_protocol::SkillsConfigWriteResponse; +use codex_features::FEATURES; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; @@ -32,49 +35,33 @@ pub(crate) fn clear_config_value(key_path: impl Into) -> ConfigEdit { replace_config_value(key_path, JsonValue::Null) } -pub(crate) fn profile_scoped_key_path(profile: Option<&str>, key_path: &str) -> String { - if let Some(profile) = profile { - let profile = serde_json::Value::String(profile.to_string()).to_string(); - format!("profiles.{profile}.{key_path}") - } else { - key_path.to_string() - } -} - pub(crate) fn app_scoped_key_path(app_id: &str, key_path: &str) -> String { let app_id = serde_json::Value::String(app_id.to_string()).to_string(); format!("apps.{app_id}.{key_path}") } pub(crate) fn build_model_selection_edits( - profile: Option<&str>, model: &str, effort: Option, ) -> Vec { let effort_edit = effort.map_or_else( - || clear_config_value(profile_scoped_key_path(profile, "model_reasoning_effort")), + || clear_config_value("model_reasoning_effort"), |effort| { replace_config_value( - profile_scoped_key_path(profile, "model_reasoning_effort"), + "model_reasoning_effort", serde_json::json!(effort.to_string()), ) }, ); vec![ - replace_config_value( - profile_scoped_key_path(profile, "model"), - serde_json::json!(model), - ), + replace_config_value("model", serde_json::json!(model)), effort_edit, ] } -pub(crate) fn build_service_tier_selection_edits( - profile: Option<&str>, - service_tier: Option<&str>, -) -> Vec { +pub(crate) fn build_service_tier_selection_edits(service_tier: Option<&str>) -> Vec { let service_tier_edit = service_tier.map_or_else( - || clear_config_value(profile_scoped_key_path(profile, "service_tier")), + || clear_config_value("service_tier"), |service_tier| { let config_value = if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE { SERVICE_TIER_DEFAULT_REQUEST_VALUE @@ -85,21 +72,62 @@ pub(crate) fn build_service_tier_selection_edits( None => service_tier, } }; - replace_config_value( - profile_scoped_key_path(profile, "service_tier"), - serde_json::json!(config_value), - ) + replace_config_value("service_tier", serde_json::json!(config_value)) }, ); vec![service_tier_edit] } +#[cfg(target_os = "windows")] +pub(crate) fn build_windows_sandbox_mode_edits(elevated_enabled: bool) -> Vec { + let feature_key_path = |feature: &str| format!("features.{feature}"); + vec![ + replace_config_value( + "windows.sandbox", + serde_json::json!(if elevated_enabled { + "elevated" + } else { + "unelevated" + }), + ), + clear_config_value(feature_key_path("experimental_windows_sandbox")), + clear_config_value(feature_key_path("elevated_windows_sandbox")), + clear_config_value(feature_key_path("enable_experimental_windows_sandbox")), + ] +} + +pub(crate) fn build_feature_enabled_edit(feature_key: &str, enabled: bool) -> ConfigEdit { + let key_path = format!("features.{feature_key}"); + let is_default_false_feature = FEATURES + .iter() + .find(|spec| spec.key == feature_key) + .is_some_and(|spec| !spec.default_enabled); + if enabled || !is_default_false_feature { + replace_config_value(key_path, serde_json::json!(enabled)) + } else { + clear_config_value(key_path) + } +} + +pub(crate) fn build_memory_settings_edits( + use_memories: bool, + generate_memories: bool, +) -> Vec { + vec![ + replace_config_value("memories.use_memories", serde_json::json!(use_memories)), + replace_config_value( + "memories.generate_memories", + serde_json::json!(generate_memories), + ), + ] +} + pub(crate) async fn write_config_batch( request_handle: AppServerRequestHandle, edits: Vec, -) -> Result<()> { +) -> Result { let request_id = RequestId::String(format!("tui-config-write-{}", Uuid::new_v4())); - let _: ConfigWriteResponse = request_handle + request_handle .request_typed(ClientRequest::ConfigBatchWrite { request_id, params: ConfigBatchWriteParams { @@ -110,8 +138,24 @@ pub(crate) async fn write_config_batch( }, }) .await - .wrap_err("config/batchWrite failed in TUI")?; - Ok(()) + .wrap_err("config/batchWrite failed in TUI") +} + +pub(crate) async fn read_effective_config( + request_handle: AppServerRequestHandle, + cwd: String, +) -> Result { + let request_id = RequestId::String(format!("tui-config-read-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::ConfigRead { + request_id, + params: ConfigReadParams { + include_layers: false, + cwd: Some(cwd), + }, + }) + .await + .wrap_err("config/read failed in TUI") } pub(crate) async fn write_skill_enabled( @@ -139,14 +183,6 @@ mod tests { use super::*; use pretty_assertions::assert_eq; - #[test] - fn profile_scoped_key_path_quotes_dotted_profile_names() { - assert_eq!( - profile_scoped_key_path(Some("team.prod"), "model"), - "profiles.\"team.prod\".model" - ); - } - #[test] fn app_scoped_key_path_quotes_dotted_app_ids() { assert_eq!( diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index e89b97ed5417..4c40e0445521 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -156,6 +156,17 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> { )); } + if let Some(allow_appshots) = requirements_toml.allow_appshots { + requirement_lines.push(requirement_line( + "allow_appshots", + allow_appshots.to_string(), + requirements + .allow_appshots + .as_ref() + .map(|sourced| &sourced.source), + )); + } + if requirements_toml.guardian_policy_config.is_some() { requirement_lines.push(requirement_line( "guardian_policy_config", @@ -662,6 +673,10 @@ mod tests { /*value*/ true, RequirementSource::CloudRequirements, )), + allow_appshots: Some(Sourced::new( + /*value*/ false, + RequirementSource::CloudRequirements, + )), feature_requirements: Some(Sourced::new( FeatureRequirementsToml { entries: BTreeMap::from([("guardian_approval".to_string(), true)]), @@ -701,6 +716,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]), allow_managed_hooks_only: Some(true), + allow_appshots: Some(false), computer_use: None, guardian_policy_config: Some("Use the managed guardian policy.".to_string()), feature_requirements: Some(FeatureRequirementsToml { @@ -763,6 +779,7 @@ mod tests { ) ); 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)") ); @@ -917,6 +934,7 @@ approval_policy = "never" remote_sandbox_config: None, allowed_web_search_modes: Some(Vec::new()), allow_managed_hooks_only: None, + allow_appshots: None, computer_use: None, guardian_policy_config: None, feature_requirements: None, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 92f4e9971211..c0522fee49a0 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -89,9 +89,9 @@ mod app_server_approval_conversions; mod app_server_session; mod approval_events; mod ascii_animation; -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "android")))] mod audio_device; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "android"))] #[allow(dead_code)] mod audio_device { use crate::app_event::RealtimeAudioDeviceKind; @@ -196,11 +196,11 @@ mod update_prompt; mod update_versions; mod updates; mod version; -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", target_os = "android")))] mod voice; mod width; mod workspace_command; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "android"))] #[allow(dead_code)] mod voice { use crate::app_event_sender::AppEventSender; @@ -278,6 +278,8 @@ pub use public_widgets::composer_input::ComposerAction; pub use public_widgets::composer_input::ComposerInput; // (tests access modules directly within the crate) +const TUI_LOG_FILE_NAME: &str = "codex-tui.log"; + #[cfg(unix)] const AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50); @@ -349,6 +351,13 @@ async fn init_state_db_for_app_server_target( } } +// TODO(jif) delete after 22/11/2026. +fn remove_legacy_tui_log_file(codex_home: &Path) { + // Shared append-only TUI logs could grow without bound. Existing processes + // may still hold the file open, so startup cleanup is best effort. + let _ = std::fs::remove_file(codex_home.join("log").join(TUI_LOG_FILE_NAME)); +} + fn remote_addr_has_explicit_port(addr: &str, parsed: &Url) -> bool { let Some(host) = parsed.host_str() else { return false; @@ -994,11 +1003,7 @@ pub async fn run_main( .await; let model_provider_override = if cli.oss { - let resolved = resolve_oss_provider( - cli.oss_provider.as_deref(), - &config_toml, - /*config_profile*/ None, - ); + let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), &config_toml); if let Some(provider) = resolved { Some(provider) @@ -1059,6 +1064,8 @@ pub async fn run_main( ) .await; + remove_legacy_tui_log_file(config.codex_home.as_path()); + let otel_originator = originator().value; let otel = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { crate::legacy_core::otel_init::build_provider( @@ -1170,47 +1177,40 @@ pub async fn run_main( } } - let log_dir = config.log_dir.clone(); - std::fs::create_dir_all(&log_dir)?; - // Open (or create) your log file, appending to it. - let mut log_file_opts = OpenOptions::new(); - log_file_opts.create(true).append(true); + let (tui_file_layer, _tui_file_log_guard) = if config_toml.log_dir.is_some() { + let log_dir = config.log_dir.clone(); + std::fs::create_dir_all(&log_dir)?; + let mut log_file_opts = OpenOptions::new(); + log_file_opts.create(true).append(true); - // Ensure the file is only readable and writable by the current user. - // Doing the equivalent to `chmod 600` on Windows is quite a bit more code - // and requires the Windows API crates, so we can reconsider that when - // Codex CLI is officially supported on Windows. - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - log_file_opts.mode(0o600); - } - - let log_file = log_file_opts.open(log_dir.join("codex-tui.log"))?; - - // Wrap file in non‑blocking writer. - let (non_blocking, _guard) = non_blocking(log_file); + // Ensure the file is only readable and writable by the current user. + // Doing the equivalent to `chmod 600` on Windows is quite a bit more + // code and requires the Windows API crates. + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + log_file_opts.mode(0o600); + } - // use RUST_LOG env var, default to info for codex crates. - let env_filter = || { - EnvFilter::try_from_default_env().unwrap_or_else(|_| { + let log_file = log_file_opts.open(log_dir.join(TUI_LOG_FILE_NAME))?; + let (non_blocking, guard) = non_blocking(log_file); + let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { EnvFilter::new("codex_core=info,codex_tui=info,codex_rmcp_client=info") - }) + }); + let file_layer = tracing_subscriber::fmt::layer() + .with_writer(non_blocking) + .with_target(true) + .with_ansi(false) + .with_span_events( + tracing_subscriber::fmt::format::FmtSpan::NEW + | tracing_subscriber::fmt::format::FmtSpan::CLOSE, + ) + .with_filter(env_filter); + (Some(file_layer), Some(guard)) + } else { + (None, None) }; - let file_layer = tracing_subscriber::fmt::layer() - .with_writer(non_blocking) - // `with_target(true)` is the default, but we previously disabled it for file output. - // Keep it enabled so we can selectively enable targets via `RUST_LOG=...` and then - // grep for a specific module/target while troubleshooting. - .with_target(true) - .with_ansi(false) - .with_span_events( - tracing_subscriber::fmt::format::FmtSpan::NEW - | tracing_subscriber::fmt::format::FmtSpan::CLOSE, - ) - .with_filter(env_filter()); - let feedback = codex_feedback::CodexFeedback::new(); let feedback_layer = feedback.logger_layer(); let feedback_metadata_layer = feedback.metadata_layer(); @@ -1240,7 +1240,7 @@ pub async fn run_main( .map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE))); let _ = tracing_subscriber::registry() - .with(file_layer) + .with(tui_file_layer) .with(feedback_layer) .with(feedback_metadata_layer) .with(log_db_layer) @@ -1662,7 +1662,6 @@ async fn run_ratatui_app( } set_default_client_residency_requirement(config.enforce_residency.value()); - let active_profile = config.active_profile.clone(); let should_show_trust_screen = should_show_trust_screen(&config); let should_prompt_windows_sandbox_nux_at_startup = cfg!(target_os = "windows") && trust_decision_was_made @@ -1720,7 +1719,6 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - active_profile, prompt, images, session_selection, @@ -1921,6 +1919,20 @@ mod tests { .await } + #[test] + fn startup_removes_legacy_tui_log_file() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let legacy_log_dir = temp_dir.path().join("log"); + std::fs::create_dir_all(&legacy_log_dir)?; + let legacy_log = legacy_log_dir.join(TUI_LOG_FILE_NAME); + std::fs::write(&legacy_log, "legacy log")?; + + remove_legacy_tui_log_file(temp_dir.path()); + + assert!(!legacy_log.exists()); + Ok(()) + } + async fn start_test_embedded_app_server( config: Config, ) -> color_eyre::Result { @@ -2370,7 +2382,7 @@ mod tests { let updated_at = chrono::DateTime::parse_from_rfc3339(meta_rfc3339)?.with_timezone(&chrono::Utc); let times = std::fs::FileTimes::new().set_modified(updated_at.into()); - OpenOptions::new() + std::fs::OpenOptions::new() .append(true) .open(rollout_path)? .set_times(times)?; diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 3dbc2c3da606..fb021a0fece9 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -272,7 +272,6 @@ struct PickerPage { #[derive(Clone)] struct SessionPickerViewPersistence { codex_home: PathBuf, - active_profile: Option, } struct SessionPickerRunOptions { @@ -369,7 +368,6 @@ async fn run_resume_picker_with_launch_context( initial_density: SessionListDensity::from(config.tui_session_picker_view), view_persistence: Some(SessionPickerViewPersistence { codex_home: config.codex_home.to_path_buf(), - active_profile: config.active_profile.clone(), }), pager_keymap: runtime_keymap.pager, list_keymap: runtime_keymap.list, @@ -415,7 +413,6 @@ pub async fn run_fork_picker_with_app_server( initial_density: SessionListDensity::from(config.tui_session_picker_view), view_persistence: Some(SessionPickerViewPersistence { codex_home: config.codex_home.to_path_buf(), - active_profile: config.active_profile.clone(), }), pager_keymap: runtime_keymap.pager, list_keymap: runtime_keymap.list, @@ -1679,7 +1676,6 @@ impl PickerState { }; ConfigEditsBuilder::new(&persistence.codex_home) - .with_profile(persistence.active_profile.as_deref()) .set_session_picker_view(SessionPickerViewMode::from(self.density)) .apply() .await @@ -4444,7 +4440,6 @@ mod tests { ); state.view_persistence = Some(SessionPickerViewPersistence { codex_home: tmp.path().to_path_buf(), - active_profile: None, }); state @@ -4463,39 +4458,6 @@ session_picker_view = "dense" ); } - #[tokio::test] - async fn ctrl_o_persists_density_preference_for_active_profile() { - let tmp = tempdir().expect("tmpdir"); - let loader = page_only_loader(|_| {}); - let mut state = PickerState::new( - FrameRequester::test_dummy(), - loader, - ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, - /*filter_cwd*/ None, - SessionPickerAction::Resume, - ); - state.view_persistence = Some(SessionPickerViewPersistence { - codex_home: tmp.path().to_path_buf(), - active_profile: Some(String::from("work")), - }); - - state - .handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)) - .await - .unwrap(); - - assert_eq!(state.density, SessionListDensity::Dense); - let contents = - std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); - assert_eq!( - contents, - r#"[profiles.work.tui] -session_picker_view = "dense" -"# - ); - } - #[tokio::test] async fn ctrl_o_keeps_toggled_density_when_persistence_fails() { let tmp = tempdir().expect("tmpdir"); @@ -4512,7 +4474,6 @@ session_picker_view = "dense" ); state.view_persistence = Some(SessionPickerViewPersistence { codex_home: codex_home_file, - active_profile: None, }); state diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index 4acf2180ec54..369e19d7fd4d 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -30,6 +30,10 @@ impl SessionLogger { let mut opts = OpenOptions::new(); opts.create(true).truncate(true).write(true); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 32123b787c61..47a6aeb6ee12 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -166,6 +166,8 @@ mod tests { } pub fn set_modes() -> Result<()> { + ensure_virtual_terminal_processing()?; + execute!(stdout(), EnableBracketedPaste)?; enable_raw_mode()?; @@ -239,12 +241,16 @@ fn restore_common( raw_mode_restore: RawModeRestore, keyboard_restore: KeyboardRestore, ) -> Result<()> { + let mut first_error = ensure_virtual_terminal_processing().err(); + match keyboard_restore { KeyboardRestore::PopStack => keyboard_modes::restore_keyboard_enhancement_stack(), KeyboardRestore::ResetAfterExit => keyboard_modes::reset_keyboard_reporting_after_exit(), } - let mut first_error = execute!(stdout(), DisableBracketedPaste).err(); + if let Err(err) = execute!(stdout(), DisableBracketedPaste) { + first_error.get_or_insert(err); + } let _ = execute!(stdout(), DisableFocusChange); if matches!(raw_mode_restore, RawModeRestore::Disable) && let Err(err) = disable_raw_mode() @@ -797,6 +803,8 @@ impl Tui { // the synchronized update, to avoid racing with the event reader. let mut pending_viewport_area = self.pending_viewport_area()?; + ensure_virtual_terminal_processing()?; + stdout().sync_update(|_| { #[cfg(unix)] if let Some(prepared) = prepared_resume.take() { @@ -854,6 +862,10 @@ impl Tui { &mut self, request: Option, ) -> std::result::Result<(), crate::pets::PetImageRenderError> { + if let Err(err) = ensure_virtual_terminal_processing() { + return Err(crate::pets::PetImageRenderError::Terminal(err)); + } + let terminal = &mut self.terminal; let state = &mut self.ambient_pet_image_state; stdout().sync_update(|_| { @@ -869,6 +881,10 @@ impl Tui { &mut self, request: Option, ) -> std::result::Result<(), crate::pets::PetImageRenderError> { + if let Err(err) = ensure_virtual_terminal_processing() { + return Err(crate::pets::PetImageRenderError::Terminal(err)); + } + let terminal = &mut self.terminal; let state = &mut self.pet_picker_preview_image_state; stdout().sync_update(|_| { @@ -887,6 +903,10 @@ impl Tui { pub fn clear_ambient_pet_image( &mut self, ) -> std::result::Result<(), crate::pets::PetImageRenderError> { + if let Err(err) = ensure_virtual_terminal_processing() { + return Err(crate::pets::PetImageRenderError::Terminal(err)); + } + crate::pets::render_ambient_pet_image( self.terminal.backend_mut(), &mut self.ambient_pet_image_state, @@ -911,6 +931,8 @@ impl Tui { .suspend_context .prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport); + ensure_virtual_terminal_processing()?; + stdout().sync_update(|_| { #[cfg(unix)] if let Some(prepared) = prepared_resume.take() { @@ -968,3 +990,51 @@ impl Tui { Ok(None) } } + +#[cfg(windows)] +fn ensure_virtual_terminal_processing() -> Result<()> { + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::System::Console::ENABLE_PROCESSED_OUTPUT; + use windows_sys::Win32::System::Console::ENABLE_VIRTUAL_TERMINAL_PROCESSING; + use windows_sys::Win32::System::Console::GetConsoleMode; + use windows_sys::Win32::System::Console::GetStdHandle; + use windows_sys::Win32::System::Console::STD_ERROR_HANDLE; + use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE; + use windows_sys::Win32::System::Console::SetConsoleMode; + + fn enable_for_handle(handle: HANDLE) -> Result<()> { + if handle == INVALID_HANDLE_VALUE || handle == 0 { + return Ok(()); + } + + let mut mode = 0; + if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { + return Ok(()); + } + + let requested = ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING; + if mode & requested == requested { + return Ok(()); + } + + if unsafe { SetConsoleMode(handle, mode | requested) } == 0 { + return Err(std::io::Error::last_os_error()); + } + + Ok(()) + } + + let stdout_handle = unsafe { GetStdHandle(STD_OUTPUT_HANDLE) }; + enable_for_handle(stdout_handle)?; + + let stderr_handle = unsafe { GetStdHandle(STD_ERROR_HANDLE) }; + enable_for_handle(stderr_handle)?; + + Ok(()) +} + +#[cfg(not(windows))] +fn ensure_virtual_terminal_processing() -> Result<()> { + Ok(()) +} diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index cb4aa662ca37..0dbb145390f8 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -47,7 +47,12 @@ impl UpdateAction { ), UpdateAction::StandaloneWindows => ( "powershell", - &["-c", "irm https://chatgpt.com/codex/install.ps1|iex"], + &[ + "-ExecutionPolicy", + "Bypass", + "-c", + "irm https://chatgpt.com/codex/install.ps1 | iex", + ], ), } } @@ -142,7 +147,12 @@ mod tests { UpdateAction::StandaloneWindows.command_args(), ( "powershell", - &["-c", "irm https://chatgpt.com/codex/install.ps1|iex"][..], + &[ + "-ExecutionPolicy", + "Bypass", + "-c", + "irm https://chatgpt.com/codex/install.ps1 | iex" + ][..], ) ); } diff --git a/codex-rs/utils/file-lock/BUILD.bazel b/codex-rs/utils/file-lock/BUILD.bazel new file mode 100644 index 000000000000..face70c53518 --- /dev/null +++ b/codex-rs/utils/file-lock/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "file-lock", + crate_name = "codex_utils_file_lock", +) diff --git a/codex-rs/utils/file-lock/Cargo.toml b/codex-rs/utils/file-lock/Cargo.toml new file mode 100644 index 000000000000..5e3877fc1630 --- /dev/null +++ b/codex-rs/utils/file-lock/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "codex-utils-file-lock" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] diff --git a/codex-rs/utils/file-lock/src/lib.rs b/codex-rs/utils/file-lock/src/lib.rs new file mode 100644 index 000000000000..fdcfbdb81e75 --- /dev/null +++ b/codex-rs/utils/file-lock/src/lib.rs @@ -0,0 +1,168 @@ +use std::fs::File; +use std::fs::create_dir; +use std::fs::remove_dir; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +const LOCK_DIR_RETRY_SLEEP: Duration = Duration::from_millis(100); + +/// Result of acquiring a blocking advisory file lock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileLockOutcome { + Acquired, + Unsupported, +} + +/// Result of attempting to acquire a non-blocking advisory file lock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TryFileLockOutcome { + Acquired, + WouldBlock, + Unsupported, +} + +/// Result of attempting to acquire a non-blocking lock directory. +#[derive(Debug, PartialEq, Eq)] +pub enum TryLockDirOutcome { + Acquired(LockDirGuard), + WouldBlock, +} + +/// Guard for a sibling lock directory created with an atomic `mkdir`. +#[derive(Debug, PartialEq, Eq)] +pub struct LockDirGuard { + path: PathBuf, +} + +impl Drop for LockDirGuard { + fn drop(&mut self) { + let _ = remove_dir(&self.path); + } +} + +/// Acquires an exclusive advisory file lock, treating unsupported file locking +/// as a distinct outcome for platforms such as Termux. +pub fn lock_exclusive_optional(file: &File) -> io::Result { + match file.lock() { + Ok(()) => Ok(FileLockOutcome::Acquired), + Err(err) if err.kind() == io::ErrorKind::Unsupported => Ok(FileLockOutcome::Unsupported), + Err(err) => Err(err), + } +} + +/// Attempts to acquire an exclusive advisory file lock without blocking, +/// preserving `WouldBlock` and unsupported file locking as distinct outcomes. +pub fn try_lock_exclusive_optional(file: &File) -> io::Result { + match file.try_lock() { + Ok(()) => Ok(TryFileLockOutcome::Acquired), + Err(std::fs::TryLockError::WouldBlock) => Ok(TryFileLockOutcome::WouldBlock), + Err(std::fs::TryLockError::Error(err)) if err.kind() == io::ErrorKind::Unsupported => { + Ok(TryFileLockOutcome::Unsupported) + } + Err(std::fs::TryLockError::Error(err)) => Err(err), + } +} + +/// Returns the sibling directory path used as a fallback lock for `path`. +pub fn sibling_lock_dir(path: &Path) -> PathBuf { + let Some(file_name) = path.file_name() else { + return path.with_file_name(".lock"); + }; + + let mut lock_name = file_name.to_os_string(); + lock_name.push(".lock"); + path.with_file_name(lock_name) +} + +/// Acquires a sibling lock directory, blocking until it is available. +pub fn acquire_sibling_lock_dir(path: &Path) -> io::Result { + loop { + match try_acquire_sibling_lock_dir(path)? { + TryLockDirOutcome::Acquired(guard) => return Ok(guard), + TryLockDirOutcome::WouldBlock => thread::sleep(LOCK_DIR_RETRY_SLEEP), + } + } +} + +/// Attempts to acquire a sibling lock directory without blocking. +pub fn try_acquire_sibling_lock_dir(path: &Path) -> io::Result { + let lock_dir = sibling_lock_dir(path); + match create_dir(&lock_dir) { + Ok(()) => Ok(TryLockDirOutcome::Acquired(LockDirGuard { path: lock_dir })), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => Ok(TryLockDirOutcome::WouldBlock), + Err(err) => Err(err), + } +} + +/// Attempts to acquire a shared advisory file lock without blocking, +/// preserving `WouldBlock` and unsupported file locking as distinct outcomes. +pub fn try_lock_shared_optional(file: &File) -> io::Result { + match file.try_lock_shared() { + Ok(()) => Ok(TryFileLockOutcome::Acquired), + Err(std::fs::TryLockError::WouldBlock) => Ok(TryFileLockOutcome::WouldBlock), + Err(std::fs::TryLockError::Error(err)) if err.kind() == io::ErrorKind::Unsupported => { + Ok(TryFileLockOutcome::Unsupported) + } + Err(std::fs::TryLockError::Error(err)) => Err(err), + } +} + +#[cfg(test)] +mod tests { + use super::TryLockDirOutcome; + use super::sibling_lock_dir; + use super::try_acquire_sibling_lock_dir; + use std::fs; + use std::path::PathBuf; + use std::time::SystemTime; + + fn unique_temp_file_path(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "codex-file-lock-{name}-{}-{nanos}", + std::process::id() + )) + } + + #[test] + fn sibling_lock_dir_appends_lock_suffix() { + let path = PathBuf::from("/tmp/history.jsonl"); + + assert_eq!( + sibling_lock_dir(&path), + PathBuf::from("/tmp/history.jsonl.lock") + ); + } + + #[test] + fn try_acquire_sibling_lock_dir_is_exclusive_until_drop() { + let path = unique_temp_file_path("exclusive"); + let lock_dir = sibling_lock_dir(&path); + let _ = fs::remove_dir_all(&lock_dir); + + let guard = match try_acquire_sibling_lock_dir(&path).expect("acquire lock dir") { + TryLockDirOutcome::Acquired(guard) => guard, + TryLockDirOutcome::WouldBlock => panic!("first lock attempt should acquire"), + }; + assert!(lock_dir.is_dir()); + + assert!(matches!( + try_acquire_sibling_lock_dir(&path).expect("try acquire held lock dir"), + TryLockDirOutcome::WouldBlock + )); + + drop(guard); + assert!(!lock_dir.exists()); + + let reacquired = try_acquire_sibling_lock_dir(&path).expect("reacquire lock dir"); + assert!(matches!(reacquired, TryLockDirOutcome::Acquired(_))); + + let _ = fs::remove_dir_all(lock_dir); + } +} diff --git a/codex-rs/utils/image/Cargo.toml b/codex-rs/utils/image/Cargo.toml index 5ac187caaa1b..7ba28f49962a 100644 --- a/codex-rs/utils/image/Cargo.toml +++ b/codex-rs/utils/image/Cargo.toml @@ -16,7 +16,12 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["fs", "rt", "rt-multi-thread", "macros"] } [dev-dependencies] +divan = { workspace = true } image = { workspace = true, features = ["jpeg", "png", "gif", "webp"] } [lib] doctest = false + +[[bench]] +name = "prompt_images" +harness = false diff --git a/codex-rs/utils/image/benches/prompt_images.rs b/codex-rs/utils/image/benches/prompt_images.rs new file mode 100644 index 000000000000..5d2bcb61cb14 --- /dev/null +++ b/codex-rs/utils/image/benches/prompt_images.rs @@ -0,0 +1,178 @@ +use std::io::Cursor; +use std::path::Path; + +use codex_utils_image::PromptImageMode; +use codex_utils_image::load_for_prompt_bytes; +use divan::Bencher; +use image::DynamicImage; +use image::ImageFormat; +use image::Rgb; +use image::RgbImage; +use image::Rgba; +use image::RgbaImage; + +const CACHE_MISS_VARIANT_COUNT: usize = 48; + +const SMALL_SCREENSHOT: ImageSize = ImageSize { + width: 1_536, + height: 864, +}; +const LARGE_SCREENSHOT: ImageSize = ImageSize { + width: 2_560, + height: 1_440, +}; +const LARGE_PHOTO: ImageSize = ImageSize { + width: 3_264, + height: 2_448, +}; + +#[derive(Clone, Copy)] +struct ImageSize { + width: u32, + height: u32, +} + +fn main() { + divan::main(); +} + +#[divan::bench] +fn small_png_screenshot_fresh_attachment(bencher: Bencher) { + bench_fresh_attachment( + bencher, + "small-screenshot.png", + cache_miss_variants(screenshot_png(SMALL_SCREENSHOT)), + ); +} + +#[divan::bench] +fn large_png_screenshot_fresh_attachment(bencher: Bencher) { + bench_fresh_attachment( + bencher, + "large-screenshot.png", + cache_miss_variants(screenshot_png(LARGE_SCREENSHOT)), + ); +} + +#[divan::bench] +fn large_jpeg_photo_fresh_attachment(bencher: Bencher) { + bench_fresh_attachment( + bencher, + "large-photo.jpg", + cache_miss_variants(photo_jpeg(LARGE_PHOTO)), + ); +} + +#[divan::bench] +fn small_png_screenshot_repeated_attachment(bencher: Bencher) { + bench_repeated_attachment( + bencher, + "small-screenshot.png", + screenshot_png(SMALL_SCREENSHOT), + ); +} + +fn bench_fresh_attachment(bencher: Bencher, path: &'static str, images: Vec>) { + let mut image_index = 0; + + bencher + // Divan excludes `with_inputs` from the measured benchmark timing. + .with_inputs(move || { + let image = images[image_index].clone(); + image_index = (image_index + 1) % images.len(); + image + }) + .bench_local_values(move |image| prepare_prompt_data_url(path, image)); +} + +fn bench_repeated_attachment(bencher: Bencher, path: &'static str, image: Vec) { + let _ = prepare_prompt_data_url(path, image.clone()); + + bencher + // Divan excludes the per-iteration input clone from measured timing. + .with_inputs(move || image.clone()) + .bench_local_values(move |image| prepare_prompt_data_url(path, image)); +} + +fn prepare_prompt_data_url(path: &str, image: Vec) -> String { + #[allow(clippy::expect_used)] + load_for_prompt_bytes(Path::new(path), image, PromptImageMode::ResizeToFit) + .expect("benchmark fixture should load") + .into_data_url() +} + +fn cache_miss_variants(image: Vec) -> Vec> { + // The loader caches by content digest. Suffixes keep this workload on the miss path. + (0..CACHE_MISS_VARIANT_COUNT) + .map(|variant| { + let mut image = image.clone(); + image.extend_from_slice(&variant.to_le_bytes()); + image + }) + .collect() +} + +/// Encodes a synthetic UI screenshot fixture for prompt image benchmarks. +fn screenshot_png(size: ImageSize) -> Vec { + let image = RgbaImage::from_fn(size.width, size.height, |x, y| { + let toolbar = y < 52; + let sidebar = x < 240; + let panel_border = x % 320 < 2 || y % 216 < 2; + let text_row = x > 270 && y > 88 && x % 19 < 13 && y % 31 < 3; + + if toolbar { + Rgba([33, 40, 52, 255]) + } else if sidebar { + let selection = y / 68 % 5 == 2; + if selection { + Rgba([65, 106, 171, 255]) + } else { + Rgba([44, 54, 67, 255]) + } + } else if panel_border { + Rgba([198, 205, 216, 255]) + } else if text_row { + Rgba([72, 82, 96, 255]) + } else { + let panel = ((x / 320) + (y / 216) * 3) % 4; + match panel { + 0 => Rgba([246, 248, 252, 255]), + 1 => Rgba([234, 241, 250, 255]), + 2 => Rgba([240, 247, 236, 255]), + _ => Rgba([250, 240, 235, 255]), + } + } + }); + + encode_fixture(DynamicImage::ImageRgba8(image), ImageFormat::Png) +} + +/// Encodes a synthetic textured photo fixture for prompt image benchmarks. +fn photo_jpeg(size: ImageSize) -> Vec { + let image = RgbImage::from_fn(size.width, size.height, |x, y| { + let x_gradient = x * 255 / size.width; + let y_gradient = y * 255 / size.height; + let texture = ((x.wrapping_mul(17) ^ y.wrapping_mul(31) ^ (x / 7) ^ (y / 11)) & 0xff) as u8; + + Rgb([ + blend_channel(x_gradient, texture, 3), + blend_channel((x_gradient + y_gradient) / 2, texture, 5), + blend_channel(255 - y_gradient, texture, 4), + ]) + }); + + encode_fixture(DynamicImage::ImageRgb8(image), ImageFormat::Jpeg) +} + +fn blend_channel(gradient: u32, texture: u8, divisor: u32) -> u8 { + ((gradient + u32::from(texture) / divisor) % 256) as u8 +} + +fn encode_fixture(image: DynamicImage, format: ImageFormat) -> Vec { + let mut encoded = Cursor::new(Vec::new()); + #[allow(clippy::expect_used)] + image + .write_to(&mut encoded, format) + .expect("benchmark fixture should encode"); + encoded.into_inner() +} diff --git a/codex-rs/utils/plugins/src/lib.rs b/codex-rs/utils/plugins/src/lib.rs index dec24d99d856..38bf68040344 100644 --- a/codex-rs/utils/plugins/src/lib.rs +++ b/codex-rs/utils/plugins/src/lib.rs @@ -14,4 +14,5 @@ pub use plugin_namespace::plugin_namespace_for_skill_path; pub struct PluginSkillRoot { pub path: AbsolutePathBuf, pub plugin_id: String, + pub plugin_root: AbsolutePathBuf, } diff --git a/codex-rs/utils/pty/README.md b/codex-rs/utils/pty/README.md index e70d7bc6afa9..7b9df30d0a56 100644 --- a/codex-rs/utils/pty/README.md +++ b/codex-rs/utils/pty/README.md @@ -60,5 +60,5 @@ Use `spawn_pipe_process_no_stdin` to force stdin closed (commands that read stdi Unit tests live in `src/lib.rs` and cover both backends (PTY Python REPL and pipe-based stdin roundtrip). Run with: ``` -cargo test -p codex-utils-pty -- --nocapture +just test -p codex-utils-pty --no-capture ``` diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml index 3b00b40ae845..53c81f0b03f7 100644 --- a/codex-rs/windows-sandbox-rs/Cargo.toml +++ b/codex-rs/windows-sandbox-rs/Cargo.toml @@ -37,6 +37,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tempfile = "3" tokio = { workspace = true, features = ["sync", "rt"] } +tracing-appender = { workspace = true } windows = { version = "0.58", features = [ "Win32_Foundation", "Win32_NetworkManagement_WindowsFirewall", diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs index f0475bc39fdb..9b178fdca9d3 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs @@ -6,7 +6,6 @@ use anyhow::Result; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; use codex_otel::StatsigMetricsSettings; -use codex_windows_sandbox::LOG_FILE_NAME; use codex_windows_sandbox::SETUP_VERSION; use codex_windows_sandbox::SetupErrorCode; use codex_windows_sandbox::SetupErrorReport; @@ -21,6 +20,7 @@ use codex_windows_sandbox::hide_newly_created_users; use codex_windows_sandbox::install_wfp_filters; use codex_windows_sandbox::is_command_cwd_root; use codex_windows_sandbox::log_note; +use codex_windows_sandbox::log_writer; use codex_windows_sandbox::path_mask_allows; use codex_windows_sandbox::sandbox_bin_dir; use codex_windows_sandbox::sandbox_dir; @@ -36,7 +36,6 @@ use serde::Serialize; use std::collections::HashSet; use std::ffi::OsStr; use std::ffi::c_void; -use std::fs::File; use std::io::Write; use std::os::windows::process::CommandExt; use std::path::Path; @@ -109,7 +108,7 @@ enum SetupMode { ReadAclsOnly, } -fn log_line(log: &mut File, msg: &str) -> Result<()> { +fn log_line(log: &mut dyn Write, msg: &str) -> Result<()> { let ts = chrono::Utc::now().to_rfc3339(); writeln!(log, "[{ts}] {msg}").map_err(|err| { anyhow::Error::new(SetupFailure::new( @@ -156,7 +155,7 @@ fn workspace_write_cap_sids_for_path( Ok(sid_strs) } -fn spawn_read_acl_helper(payload: &Payload, _log: &mut File) -> Result<()> { +fn spawn_read_acl_helper(payload: &Payload, _log: &mut dyn Write) -> Result<()> { let mut read_payload = payload.clone(); read_payload.mode = SetupMode::ReadAclsOnly; read_payload.refresh_only = true; @@ -182,7 +181,7 @@ struct ReadAclSubjects<'a> { fn apply_read_acls( read_roots: &[PathBuf], subjects: &ReadAclSubjects<'_>, - log: &mut File, + log: &mut dyn Write, refresh_errors: &mut Vec, access_mask: u32, access_label: &str, @@ -259,7 +258,7 @@ fn read_mask_allows_or_log( read_mask: u32, access_label: &str, refresh_errors: &mut Vec, - log: &mut File, + log: &mut dyn Write, ) -> Result { match path_mask_allows(root, psids, read_mask, /*require_all_bits*/ true) { Ok(has) => Ok(has), @@ -294,7 +293,7 @@ fn lock_sandbox_dir( sandbox_group_access_mode: i32, sandbox_group_mask: u32, real_user_mask: u32, - _log: &mut File, + _log: &mut dyn Write, ) -> Result<()> { std::fs::create_dir_all(dir)?; let system_sid = resolve_sid("SYSTEM")?; @@ -391,8 +390,7 @@ pub fn main() -> Result<()> { if let Ok(codex_home) = std::env::var("CODEX_HOME") { let sbx_dir = sandbox_dir(Path::new(&codex_home)); let _ = std::fs::create_dir_all(&sbx_dir); - let log_path = sbx_dir.join(LOG_FILE_NAME); - if let Ok(mut f) = File::options().create(true).append(true).open(&log_path) { + if let Some(mut f) = log_writer(&sbx_dir) { let _ = writeln!( f, "[{}] top-level error: {}", @@ -442,17 +440,12 @@ fn real_main() -> Result<()> { format!("failed to create sandbox dir {}: {err}", sbx_dir.display()), )) })?; - let log_path = sbx_dir.join(LOG_FILE_NAME); - let mut log = File::options() - .create(true) - .append(true) - .open(&log_path) - .map_err(|err| { - anyhow::Error::new(SetupFailure::new( - SetupErrorCode::HelperLogFailed, - format!("open log {} failed: {err}", log_path.display()), - )) - })?; + let mut log = log_writer(&sbx_dir).ok_or_else(|| { + anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperLogFailed, + format!("open log in {} failed", sbx_dir.display()), + )) + })?; let result = run_setup(&payload, &mut log, &sbx_dir); if let Err(err) = &result { let _ = log_line(&mut log, &format!("setup error: {err:?}")); @@ -480,14 +473,14 @@ fn real_main() -> Result<()> { result } -fn run_setup(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<()> { +fn run_setup(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Result<()> { match payload.mode { SetupMode::ReadAclsOnly => run_read_acl_only(payload, log), SetupMode::Full => run_setup_full(payload, log, sbx_dir), } } -fn run_read_acl_only(payload: &Payload, log: &mut File) -> Result<()> { +fn run_read_acl_only(payload: &Payload, log: &mut dyn Write) -> Result<()> { let _read_acl_guard = match acquire_read_acl_mutex()? { Some(guard) => guard, None => { @@ -550,7 +543,7 @@ fn run_read_acl_only(payload: &Payload, log: &mut File) -> Result<()> { Ok(()) } -fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<()> { +fn run_setup_full(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Result<()> { let refresh_only = payload.refresh_only; if !refresh_only { let provision_result = provision_sandbox_users( diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs index 0a839508536d..bf2476dd123c 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use std::fs::File; use std::io::Write; use windows::Win32::Foundation::S_OK; @@ -57,7 +56,7 @@ pub fn ensure_offline_proxy_allowlist( offline_sid: &str, proxy_ports: &[u16], allow_local_binding: bool, - log: &mut File, + log: &mut dyn Write, ) -> Result<()> { let local_user_spec = format!("O:LSD:(A;;CC;;;{offline_sid})"); @@ -154,7 +153,7 @@ pub fn ensure_offline_proxy_allowlist( result } -pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut File) -> Result<()> { +pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut dyn Write) -> Result<()> { let local_user_spec = format!("O:LSD:(A;;CC;;;{offline_sid})"); let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; @@ -206,7 +205,11 @@ pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut File) -> Resul result } -fn remove_rule_if_present(rules: &INetFwRules, internal_name: &str, log: &mut File) -> Result<()> { +fn remove_rule_if_present( + rules: &INetFwRules, + internal_name: &str, + log: &mut dyn Write, +) -> Result<()> { let name = BSTR::from(internal_name); if unsafe { rules.Item(&name) }.is_ok() { unsafe { rules.Remove(&name) }.map_err(|err| { @@ -266,7 +269,11 @@ fn validate_local_policy_modify_result( ))) } -fn ensure_block_rule(rules: &INetFwRules, spec: &BlockRuleSpec<'_>, log: &mut File) -> Result<()> { +fn ensure_block_rule( + rules: &INetFwRules, + spec: &BlockRuleSpec<'_>, + log: &mut dyn Write, +) -> Result<()> { let name = BSTR::from(spec.internal_name); let rule: INetFwRule3 = match unsafe { rules.Item(&name) } { Ok(existing) => existing.cast().map_err(|err| { @@ -453,7 +460,7 @@ fn port_range_string(start: u32, end: u32) -> String { } } -fn log_line(log: &mut File, msg: &str) -> Result<()> { +fn log_line(log: &mut dyn Write, msg: &str) -> Result<()> { let ts = chrono::Utc::now().to_rfc3339(); writeln!(log, "[{ts}] {msg}")?; Ok(()) diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs index de76b2413b36..7f21f185ee14 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs @@ -7,7 +7,7 @@ use rand::rngs::SmallRng; use serde::Serialize; use std::ffi::OsStr; use std::ffi::c_void; -use std::fs::File; +use std::io::Write; use std::path::Path; use std::path::PathBuf; use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; @@ -49,7 +49,7 @@ const SID_AUTHENTICATED_USERS: &str = "S-1-5-11"; const SID_EVERYONE: &str = "S-1-1-0"; const SID_SYSTEM: &str = "S-1-5-18"; -pub fn ensure_sandbox_users_group(log: &mut File) -> Result<()> { +pub fn ensure_sandbox_users_group(log: &mut dyn Write) -> Result<()> { ensure_local_group(SANDBOX_USERS_GROUP, SANDBOX_USERS_GROUP_COMMENT, log) } @@ -63,7 +63,7 @@ pub fn provision_sandbox_users( online_username: &str, proxy_ports: &[u16], allow_local_binding: bool, - log: &mut File, + log: &mut dyn Write, ) -> Result<()> { ensure_sandbox_users_group(log)?; super::log_line( @@ -86,13 +86,13 @@ pub fn provision_sandbox_users( Ok(()) } -pub fn ensure_sandbox_user(username: &str, password: &str, log: &mut File) -> Result<()> { +pub fn ensure_sandbox_user(username: &str, password: &str, log: &mut dyn Write) -> Result<()> { ensure_local_user(username, password, log)?; ensure_local_group_member(SANDBOX_USERS_GROUP, username)?; Ok(()) } -pub fn ensure_local_user(name: &str, password: &str, log: &mut File) -> Result<()> { +pub fn ensure_local_user(name: &str, password: &str, log: &mut dyn Write) -> Result<()> { let name_w = to_wide(OsStr::new(name)); let pwd_w = to_wide(OsStr::new(password)); unsafe { @@ -156,7 +156,7 @@ pub fn ensure_local_user(name: &str, password: &str, log: &mut File) -> Result<( Ok(()) } -pub fn ensure_local_group(name: &str, comment: &str, log: &mut File) -> Result<()> { +pub fn ensure_local_group(name: &str, comment: &str, log: &mut dyn Write) -> Result<()> { const ERROR_ALIAS_EXISTS: u32 = 1379; const NERR_GROUP_EXISTS: u32 = 2223; diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs index be8b0c67e784..47cacf72e0cf 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs @@ -1,5 +1,5 @@ use std::ffi::c_void; -use std::fs::File; +use std::io::Write; use std::path::PathBuf; use anyhow::Result; @@ -13,7 +13,7 @@ use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; pub(super) fn ensure_codex_app_runtime_bin_readable( sandbox_group_psid: *mut c_void, refresh_errors: &mut Vec, - log: &mut File, + log: &mut dyn Write, ) -> Result<()> { let local_app_data = std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 43b3f7195528..ca9d6c16f301 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -183,10 +183,14 @@ pub use ipc_framed::read_frame; #[cfg(target_os = "windows")] pub use ipc_framed::write_frame; #[cfg(target_os = "windows")] -pub use logging::LOG_FILE_NAME; +pub use logging::current_log_file_path; +#[cfg(target_os = "windows")] +pub use logging::log_file_path_for_utc_date; #[cfg(target_os = "windows")] pub use logging::log_note; #[cfg(target_os = "windows")] +pub use logging::log_writer; +#[cfg(target_os = "windows")] pub use path_normalization::canonicalize_path; #[cfg(target_os = "windows")] pub use policy::SandboxPolicy; diff --git a/codex-rs/windows-sandbox-rs/src/logging.rs b/codex-rs/windows-sandbox-rs/src/logging.rs index 1c0d695f3244..f5e839970995 100644 --- a/codex-rs/windows-sandbox-rs/src/logging.rs +++ b/codex-rs/windows-sandbox-rs/src/logging.rs @@ -1,13 +1,16 @@ -use std::fs::OpenOptions; use std::io::Write; use std::path::Path; use std::path::PathBuf; use std::sync::OnceLock; use codex_utils_string::take_bytes_at_char_boundary; +use tracing_appender::rolling::RollingFileAppender; +use tracing_appender::rolling::Rotation; const LOG_COMMAND_PREVIEW_LIMIT: usize = 200; -pub const LOG_FILE_NAME: &str = "sandbox.log"; +pub const LOG_FILE_PREFIX: &str = "sandbox"; +pub const LOG_FILE_SUFFIX: &str = "log"; +pub const MAX_LOG_FILES: usize = 90; fn exe_label() -> &'static str { static LABEL: OnceLock = OnceLock::new(); @@ -28,18 +31,35 @@ fn preview(command: &[String]) -> String { } } -fn log_file_path(base_dir: &Path) -> Option { - if base_dir.is_dir() { - Some(base_dir.join(LOG_FILE_NAME)) - } else { - None +pub fn log_file_path_for_utc_date(base_dir: &Path, date: chrono::NaiveDate) -> PathBuf { + base_dir.join(format!( + "{LOG_FILE_PREFIX}.{}.{}", + date.format("%Y-%m-%d"), + LOG_FILE_SUFFIX + )) +} + +pub fn current_log_file_path(base_dir: &Path) -> PathBuf { + log_file_path_for_utc_date(base_dir, chrono::Utc::now().date_naive()) +} + +pub fn log_writer(base_dir: &Path) -> Option { + if !base_dir.is_dir() { + return None; } + + RollingFileAppender::builder() + .rotation(Rotation::DAILY) + .filename_prefix(LOG_FILE_PREFIX) + .filename_suffix(LOG_FILE_SUFFIX) + .max_log_files(MAX_LOG_FILES) + .build(base_dir) + .ok() } fn append_line(line: &str, base_dir: Option<&Path>) { if let Some(dir) = base_dir - && let Some(path) = log_file_path(dir) - && let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) + && let Some(mut f) = log_writer(dir) { let _ = writeln!(f, "{line}"); } @@ -68,7 +88,7 @@ pub fn debug_log(msg: &str, base_dir: Option<&Path>) { } } -// Unconditional note logging to sandbox.log +// Unconditional note logging to the daily sandbox log. pub fn log_note(msg: &str, base_dir: Option<&Path>) { let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); append_line(&format!("[{ts} {}] {}", exe_label(), msg), base_dir); @@ -88,4 +108,38 @@ mod tests { let previewed = result.unwrap(); assert!(previewed.len() <= LOG_COMMAND_PREVIEW_LIMIT); } + + #[test] + fn log_note_writes_to_daily_rolling_log() { + let tempdir = tempfile::tempdir().expect("tempdir"); + + log_note("hello daily log", Some(tempdir.path())); + + let entries = std::fs::read_dir(tempdir.path()) + .expect("read log dir") + .collect::, _>>() + .expect("read entries"); + assert_eq!(entries.len(), 1); + + let log_path = entries[0].path(); + let filename = log_path + .file_name() + .and_then(|name| name.to_str()) + .expect("utf-8 filename"); + assert!(filename.starts_with("sandbox.")); + assert!(filename.ends_with(".log")); + + let log = std::fs::read_to_string(log_path).expect("read log"); + assert!(log.contains("hello daily log")); + } + + #[test] + fn log_file_path_for_utc_date_matches_rolling_appender_name() { + let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 21).expect("valid date"); + + assert_eq!( + log_file_path_for_utc_date(Path::new("logs"), date), + PathBuf::from("logs").join("sandbox.2026-05-21.log") + ); + } } diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs index 9cd3d6d96d2f..e2830ad8a1c7 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs @@ -70,7 +70,7 @@ fn sandbox_home(name: &str) -> TempDir { } fn sandbox_log(codex_home: &Path) -> String { - let log_path = codex_home.join(".sandbox").join("sandbox.log"); + let log_path = crate::current_log_file_path(&codex_home.join(".sandbox")); fs::read_to_string(&log_path) .unwrap_or_else(|err| format!("failed to read {}: {err}", log_path.display())) } diff --git a/docs/contributing.md b/docs/contributing.md index 19b31073e944..aeae1f10d3cd 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -54,7 +54,7 @@ When a change updates model catalogs or model metadata (`/models` payloads, pres - Fill in the PR template (or include similar information) - **What? Why? How?** - Include a link to a bug report or enhancement request in the issue tracker -- Run **all** checks locally. Use the root `just` helpers so you stay consistent with the rest of the workspace: `just fmt`, `just fix -p ` for the crate you touched, and the relevant tests (e.g., `cargo test -p codex-tui` or `just test` if you need a full sweep). CI failures that could have been caught locally slow down the process. +- Run **all** checks locally. Use the root `just` helpers so you stay consistent with the rest of the workspace: `just fmt`, `just fix -p ` for the crate you touched, and the relevant tests (e.g., `just test -p codex-tui` or `just test` if you need a full sweep). CI failures that could have been caught locally slow down the process. - Make sure your branch is up-to-date with `main` and that you have resolved merge conflicts. - Mark the PR as **Ready for review** only when you believe it is in a merge-able state. diff --git a/docs/install.md b/docs/install.md index 0991e7d16c93..2cd2cbe5cd6f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -26,7 +26,7 @@ rustup component add rustfmt rustup component add clippy # Install helper tools used by the workspace justfile: cargo install --locked just -# Optional: install nextest for the `just test` helper +# Install nextest for the `just test` helper. cargo install --locked cargo-nextest # Build Codex. @@ -40,25 +40,24 @@ just fmt just fix -p # Run the relevant tests (project-specific is fastest), for example: -cargo test -p codex-tui -# If you have cargo-nextest installed, `just test` runs the test suite via nextest: +just test -p codex-tui +# `just test` runs the test suite via nextest: just test # Avoid `--all-features` for routine local runs because it increases build # time and `target/` disk usage by compiling additional feature combinations. -# If you specifically want full feature coverage, use: -cargo test --all-features ``` ## Tracing / verbose logging Codex is written in Rust, so it honors the `RUST_LOG` environment variable to configure its logging behavior. -The TUI defaults to `RUST_LOG=codex_core=info,codex_tui=info,codex_rmcp_client=info` and log messages are written to `~/.codex/log/codex-tui.log` by default. For a single run, you can override the log directory with `-c log_dir=...` (for example, `-c log_dir=./.codex-log`). +The TUI records diagnostics in bounded local stores by default. Set `log_dir` explicitly to enable a plaintext TUI log for a run: ```bash -tail -F ~/.codex/log/codex-tui.log +codex -c log_dir=./.codex-log +tail -F ./.codex-log/codex-tui.log ``` -By comparison, the non-interactive mode (`codex exec`) defaults to `RUST_LOG=error`, but messages are printed inline, so there is no need to monitor a separate file. +The non-interactive mode (`codex exec`) defaults to `RUST_LOG=error`, but messages are printed inline, so there is no need to monitor a separate file. See the Rust documentation on [`RUST_LOG`](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) for more information on the configuration options. diff --git a/justfile b/justfile index ab2fbc63629a..85324d02cd2b 100644 --- a/justfile +++ b/justfile @@ -46,14 +46,22 @@ install: rustup show active-toolchain cargo fetch -# Run `cargo nextest` since it's faster than `cargo test`, though including -# --no-fail-fast is important to ensure all tests are run. +# Run nextest with --no-fail-fast so all tests are run. # # Run `cargo install --locked cargo-nextest` if you don't have it installed. # Prefer this for routine local runs. Workspace crate features are banned, so # there should be no need to add `--all-features`. -test: - RUST_MIN_STACK={{ rust_min_stack }} cargo nextest run --no-fail-fast +test *args: + RUST_MIN_STACK={{ rust_min_stack }} cargo nextest run --no-fail-fast "$@" + just bench-smoke + +# Run explicit workspace benchmark targets. +bench *args: + cargo bench --workspace --bench '*' "$@" + +# Run benchmark targets once to ensure they start successfully. +bench-smoke: + just bench -- --test # Build and run Codex from source using Bazel. # Note we have to use the combination of `[no-cd]` and `--run_under="cd $PWD &&"` @@ -116,6 +124,11 @@ argument-comment-lint *args: argument-comment-lint-from-source *args: {{ justfile_directory() }}/tools/argument-comment-lint/run.py "$@" +# Audit advisory file locks that may need Termux Unsupported handling. +[no-cd] +termux-lock-audit *args: + {{ justfile_directory() }}/scripts/termux-lock-audit.sh "$@" + # Tail logs from the state SQLite database log *args: if [ "${1:-}" = "--" ]; then shift; fi; cargo run -p codex-state --bin logs_client -- "$@" diff --git a/scripts/codex_package/README.md b/scripts/codex_package/README.md index 8a1b392b20ff..af070e2c4404 100644 --- a/scripts/codex_package/README.md +++ b/scripts/codex_package/README.md @@ -55,6 +55,13 @@ corresponding resource flags: `--bwrap-bin` for Linux packages, and Windows packages. This keeps package archive creation as a pure staging step after signing instead of rebuilding resources. +When the builder source-builds an entrypoint for a Darwin or Linux target, it +downloads and verifies the matching Codex-built V8 release pair before invoking +Cargo and sets `RUSTY_V8_ARCHIVE` plus `RUSTY_V8_SRC_BINDING_PATH` for that +build. Windows targets keep Cargo's release-build MSVC artifact path. Explicit +overrides remain authoritative when both variables are already set. Set +`V8_FROM_SOURCE=1` to leave the build with the `v8` crate source-build path. + `rg` is not built from this repository, so the builder fetches it from the DotSlash manifest at `scripts/codex_package/rg`. Downloaded archives are cached under `$TMPDIR/codex-package/-rg` and are reused only after the recorded diff --git a/scripts/codex_package/cargo.py b/scripts/codex_package/cargo.py index f7fbf0f9ad8a..f2238dce5337 100644 --- a/scripts/codex_package/cargo.py +++ b/scripts/codex_package/cargo.py @@ -8,6 +8,7 @@ from .targets import REPO_ROOT from .targets import PackageVariant from .targets import TargetSpec +from .v8 import resolve_codex_v8_cargo_env CODEX_RS_ROOT = REPO_ROOT / "codex-rs" @@ -60,8 +61,19 @@ def build_source_binaries( for binary in binaries: cmd.extend(["--bin", binary]) + cargo_env = None + if entrypoint_bin is None: + codex_v8_env = resolve_codex_v8_cargo_env(spec) + if codex_v8_env: + cargo_env = {**os.environ, **codex_v8_env} + print("+", " ".join(cmd)) - subprocess.run(cmd, cwd=CODEX_RS_ROOT, check=True) + subprocess.run( + cmd, + cwd=CODEX_RS_ROOT, + check=True, + env=cargo_env, + ) output_dir = cargo_profile_output_dir(spec, profile) outputs = SourceBuildOutputs( diff --git a/scripts/codex_package/dotslash.py b/scripts/codex_package/dotslash.py new file mode 100644 index 000000000000..9252ae29f1ab --- /dev/null +++ b/scripts/codex_package/dotslash.py @@ -0,0 +1,222 @@ +"""Fetch executable artifacts from checked-in DotSlash manifests.""" + +import hashlib +import json +import shutil +import stat +import tarfile +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlparse +from urllib.request import urlopen + +from .targets import TargetSpec + + +DOWNLOAD_TIMEOUT_SECS = 60 + + +@dataclass(frozen=True) +class DotSlashArtifact: + size: int + digest: str + archive_format: str + archive_member: str + url: str + + +def fetch_dotslash_executable( + spec: TargetSpec, + *, + manifest_path: Path, + artifact_label: str, + cache_key: str, + dest_name: str, + missing_ok: bool = False, +) -> Path | None: + artifact = artifact_for_target( + spec, + manifest_path, + artifact_label=artifact_label, + missing_ok=missing_ok, + ) + if artifact is None: + return None + + cache_dir = default_cache_root() / cache_key + archive_path = cache_dir / archive_filename(artifact.url) + + if not archive_is_valid(archive_path, artifact, artifact_label): + download_archive(artifact.url, archive_path) + try: + verify_archive(archive_path, artifact, artifact_label) + except RuntimeError: + archive_path.unlink(missing_ok=True) + raise + + dest = cache_dir / dest_name + extract_archive_member(archive_path, artifact, dest, artifact_label) + if not spec.is_windows: + mode = dest.stat().st_mode + dest.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return dest + + +def artifact_for_target( + spec: TargetSpec, + manifest_path: Path, + *, + artifact_label: str, + missing_ok: bool = False, +) -> DotSlashArtifact | None: + manifest = load_manifest(manifest_path) + platform_info = manifest.get("platforms", {}).get(spec.dotslash_platform) + if platform_info is None: + if missing_ok: + return None + raise RuntimeError( + f"{artifact_label} manifest {manifest_path} is missing platform " + f"{spec.dotslash_platform!r}" + ) + + providers = platform_info.get("providers") + if not providers: + raise RuntimeError( + f"{artifact_label} manifest {manifest_path} has no providers for " + f"{spec.dotslash_platform!r}" + ) + + hash_name = platform_info.get("hash") + if hash_name != "sha256": + raise RuntimeError( + f"Unsupported {artifact_label} hash {hash_name!r} for " + f"{spec.dotslash_platform!r}; expected sha256" + ) + + return DotSlashArtifact( + size=int(platform_info["size"]), + digest=str(platform_info["digest"]), + archive_format=str(platform_info["format"]), + archive_member=str(platform_info["path"]), + url=str(providers[0]["url"]), + ) + + +def load_manifest(manifest_path: Path) -> dict: + text = manifest_path.read_text(encoding="utf-8") + if text.startswith("#!"): + text = "\n".join(text.splitlines()[1:]) + return json.loads(text) + + +def default_cache_root() -> Path: + return Path(tempfile.gettempdir()) / "codex-package" + + +def archive_filename(url: str) -> str: + filename = Path(urlparse(url).path).name + if not filename: + raise RuntimeError(f"Unable to determine archive filename from {url}") + return filename + + +def archive_is_valid( + archive_path: Path, + artifact: DotSlashArtifact, + artifact_label: str, +) -> bool: + if not archive_path.is_file(): + return False + try: + verify_archive(archive_path, artifact, artifact_label) + except RuntimeError: + archive_path.unlink(missing_ok=True) + return False + return True + + +def verify_archive( + archive_path: Path, + artifact: DotSlashArtifact, + artifact_label: str, +) -> None: + actual_size = archive_path.stat().st_size + if actual_size != artifact.size: + raise RuntimeError( + f"{artifact_label} archive {archive_path} has size {actual_size}, " + f"expected {artifact.size}" + ) + + digest = hashlib.sha256() + with open(archive_path, "rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + digest.update(chunk) + + actual_digest = digest.hexdigest() + if actual_digest != artifact.digest: + raise RuntimeError( + f"{artifact_label} archive {archive_path} has sha256 {actual_digest}, " + f"expected {artifact.digest}" + ) + + +def download_archive(url: str, archive_path: Path) -> None: + archive_path.parent.mkdir(parents=True, exist_ok=True) + temp_path = archive_path.with_suffix(f"{archive_path.suffix}.tmp") + temp_path.unlink(missing_ok=True) + try: + with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response: + with open(temp_path, "wb") as out: + shutil.copyfileobj(response, out) + temp_path.replace(archive_path) + finally: + temp_path.unlink(missing_ok=True) + + +def extract_archive_member( + archive_path: Path, + artifact: DotSlashArtifact, + dest: Path, + artifact_label: str, +) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + dest.unlink(missing_ok=True) + + if artifact.archive_format == "tar.gz": + with tarfile.open(archive_path, "r:gz") as archive: + try: + member = archive.getmember(artifact.archive_member) + except KeyError as exc: + raise RuntimeError( + f"{artifact_label} archive {archive_path} is missing " + f"{artifact.archive_member!r}" + ) from exc + + extracted = archive.extractfile(member) + if extracted is None: + raise RuntimeError( + f"{artifact_label} archive member {artifact.archive_member!r} is not a file" + ) + with extracted, open(dest, "wb") as out: + shutil.copyfileobj(extracted, out) + return + + if artifact.archive_format == "zip": + with zipfile.ZipFile(archive_path) as archive: + try: + with archive.open(artifact.archive_member) as extracted: + with open(dest, "wb") as out: + shutil.copyfileobj(extracted, out) + except KeyError as exc: + raise RuntimeError( + f"{artifact_label} archive {archive_path} is missing " + f"{artifact.archive_member!r}" + ) from exc + return + + raise RuntimeError( + f"Unsupported {artifact_label} archive format {artifact.archive_format!r}; " + "expected tar.gz or zip" + ) diff --git a/scripts/codex_package/ripgrep.py b/scripts/codex_package/ripgrep.py index ce3ad7dc3424..33a484022b36 100644 --- a/scripts/codex_package/ripgrep.py +++ b/scripts/codex_package/ripgrep.py @@ -1,33 +1,14 @@ """Fetch ripgrep from the DotSlash manifest used by the package builder.""" -import hashlib -import json -import shutil -import stat -import tarfile -import tempfile -import zipfile -from dataclasses import dataclass from pathlib import Path -from urllib.parse import urlparse -from urllib.request import urlopen +from .dotslash import fetch_dotslash_executable from .targets import REPO_ROOT from .targets import TargetSpec from .targets import resolve_input_path RG_MANIFEST = REPO_ROOT / "scripts" / "codex_package" / "rg" -DOWNLOAD_TIMEOUT_SECS = 60 - - -@dataclass(frozen=True) -class RgArtifact: - size: int - digest: str - archive_format: str - archive_member: str - url: str def resolve_rg_bin(spec: TargetSpec, rg_bin: Path | None) -> Path: @@ -41,155 +22,14 @@ def fetch_rg( spec: TargetSpec, *, manifest_path: Path = RG_MANIFEST, - cache_root: Path | None = None, ) -> Path: - artifact = artifact_for_target(spec, manifest_path) - cache_dir = (cache_root or default_cache_root()) / f"{spec.target}-rg" - archive_path = cache_dir / archive_filename(artifact.url) - - if not archive_is_valid(archive_path, artifact): - download_archive(artifact.url, archive_path) - try: - verify_archive(archive_path, artifact) - except RuntimeError: - archive_path.unlink(missing_ok=True) - raise - - dest = cache_dir / spec.rg_name - extract_rg(archive_path, artifact, dest) - if not spec.is_windows: - mode = dest.stat().st_mode - dest.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - return dest - - -def artifact_for_target(spec: TargetSpec, manifest_path: Path) -> RgArtifact: - manifest = load_manifest(manifest_path) - try: - platform_info = manifest["platforms"][spec.dotslash_platform] - except KeyError as exc: - raise RuntimeError( - f"ripgrep manifest {manifest_path} is missing platform {spec.dotslash_platform!r}" - ) from exc - - providers = platform_info.get("providers") - if not providers: - raise RuntimeError( - f"ripgrep manifest {manifest_path} has no providers for {spec.dotslash_platform!r}" - ) - - hash_name = platform_info.get("hash") - if hash_name != "sha256": - raise RuntimeError( - f"Unsupported ripgrep hash {hash_name!r} for " - f"{spec.dotslash_platform!r}; expected sha256" - ) - - return RgArtifact( - size=int(platform_info["size"]), - digest=str(platform_info["digest"]), - archive_format=str(platform_info["format"]), - archive_member=str(platform_info["path"]), - url=str(providers[0]["url"]), - ) - - -def load_manifest(manifest_path: Path) -> dict: - text = manifest_path.read_text(encoding="utf-8") - if text.startswith("#!"): - text = "\n".join(text.splitlines()[1:]) - return json.loads(text) - - -def default_cache_root() -> Path: - return Path(tempfile.gettempdir()) / "codex-package" - - -def archive_filename(url: str) -> str: - filename = Path(urlparse(url).path).name - if not filename: - raise RuntimeError(f"Unable to determine archive filename from {url}") - return filename - - -def archive_is_valid(archive_path: Path, artifact: RgArtifact) -> bool: - if not archive_path.is_file(): - return False - try: - verify_archive(archive_path, artifact) - except RuntimeError: - archive_path.unlink(missing_ok=True) - return False - return True - - -def verify_archive(archive_path: Path, artifact: RgArtifact) -> None: - actual_size = archive_path.stat().st_size - if actual_size != artifact.size: - raise RuntimeError( - f"ripgrep archive {archive_path} has size {actual_size}, expected {artifact.size}" - ) - - digest = hashlib.sha256() - with open(archive_path, "rb") as fh: - for chunk in iter(lambda: fh.read(1024 * 1024), b""): - digest.update(chunk) - - actual_digest = digest.hexdigest() - if actual_digest != artifact.digest: - raise RuntimeError( - f"ripgrep archive {archive_path} has sha256 {actual_digest}, " - f"expected {artifact.digest}" - ) - - -def download_archive(url: str, archive_path: Path) -> None: - archive_path.parent.mkdir(parents=True, exist_ok=True) - temp_path = archive_path.with_suffix(f"{archive_path.suffix}.tmp") - temp_path.unlink(missing_ok=True) - try: - with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response: - with open(temp_path, "wb") as out: - shutil.copyfileobj(response, out) - temp_path.replace(archive_path) - finally: - temp_path.unlink(missing_ok=True) - - -def extract_rg(archive_path: Path, artifact: RgArtifact, dest: Path) -> None: - dest.parent.mkdir(parents=True, exist_ok=True) - dest.unlink(missing_ok=True) - - if artifact.archive_format == "tar.gz": - with tarfile.open(archive_path, "r:gz") as archive: - try: - member = archive.getmember(artifact.archive_member) - except KeyError as exc: - raise RuntimeError( - f"ripgrep archive {archive_path} is missing {artifact.archive_member!r}" - ) from exc - - extracted = archive.extractfile(member) - if extracted is None: - raise RuntimeError( - f"ripgrep archive member {artifact.archive_member!r} is not a file" - ) - with extracted, open(dest, "wb") as out: - shutil.copyfileobj(extracted, out) - return - - if artifact.archive_format == "zip": - with zipfile.ZipFile(archive_path) as archive: - try: - with archive.open(artifact.archive_member) as extracted: - with open(dest, "wb") as out: - shutil.copyfileobj(extracted, out) - except KeyError as exc: - raise RuntimeError( - f"ripgrep archive {archive_path} is missing {artifact.archive_member!r}" - ) from exc - return - - raise RuntimeError( - f"Unsupported ripgrep archive format {artifact.archive_format!r}; expected tar.gz or zip" + rg_bin = fetch_dotslash_executable( + spec, + manifest_path=manifest_path, + artifact_label="ripgrep", + cache_key=f"{spec.target}-rg", + dest_name=spec.rg_name, ) + if rg_bin is None: + raise AssertionError("ripgrep is required for all package targets") + return rg_bin diff --git a/scripts/codex_package/v8.py b/scripts/codex_package/v8.py new file mode 100644 index 000000000000..43d0dcb6117f --- /dev/null +++ b/scripts/codex_package/v8.py @@ -0,0 +1,173 @@ +"""Codex-built V8 artifact overrides for package Cargo builds.""" + +from __future__ import annotations + +import hashlib +import os +import shutil +import tempfile +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from urllib.request import urlopen + +from .targets import REPO_ROOT +from .targets import TargetSpec + + +DOWNLOAD_TIMEOUT_SECS = 120 + + +@dataclass(frozen=True) +class RustyV8ArtifactPair: + archive: Path + binding: Path + + +def resolve_codex_v8_cargo_env( + spec: TargetSpec, + *, + environ: Mapping[str, str] | None = None, + cache_root: Path | None = None, +) -> dict[str, str]: + if spec.is_windows: + return {} + + environ = os.environ if environ is None else environ + if environ.get("V8_FROM_SOURCE") in {"true", "1", "yes"}: + return {} + + archive_override = environ.get("RUSTY_V8_ARCHIVE") + binding_override = environ.get("RUSTY_V8_SRC_BINDING_PATH") + if archive_override and binding_override: + return {} + if archive_override or binding_override: + raise RuntimeError( + "Cargo package builds need RUSTY_V8_ARCHIVE and " + "RUSTY_V8_SRC_BINDING_PATH set together." + ) + + artifacts = fetch_codex_v8_artifacts(spec, cache_root=cache_root) + return { + "RUSTY_V8_ARCHIVE": str(artifacts.archive), + "RUSTY_V8_SRC_BINDING_PATH": str(artifacts.binding), + } + + +def fetch_codex_v8_artifacts( + spec: TargetSpec, + *, + version: str | None = None, + cache_root: Path | None = None, +) -> RustyV8ArtifactPair: + if spec.is_windows: + raise RuntimeError(f"No Codex-built V8 release artifacts for target: {spec.target}") + + version = version or resolved_v8_crate_version() + release_url = ( + "https://github.com/openai/codex/releases/download/" + f"rusty-v8-v{version}" + ) + target = spec.target + cache_dir = (cache_root or default_cache_root()) / f"rusty-v8-{version}-{target}" + archive = cache_dir / f"librusty_v8_release_{target}.a.gz" + binding = cache_dir / f"src_binding_release_{target}.rs" + checksums = cache_dir / f"rusty_v8_release_{target}.sha256" + + download_file(f"{release_url}/{checksums.name}", checksums) + expected_checksums = load_checksums(checksums, {archive.name, binding.name}) + for artifact in [archive, binding]: + ensure_valid_artifact( + artifact, + expected_checksums[artifact.name], + f"{release_url}/{artifact.name}", + ) + + return RustyV8ArtifactPair(archive=archive, binding=binding) + + +def resolved_v8_crate_version() -> str: + import tomllib + + cargo_lock = tomllib.loads((REPO_ROOT / "codex-rs" / "Cargo.lock").read_text()) + versions = sorted( + { + package["version"] + for package in cargo_lock["package"] + if package["name"] == "v8" + } + ) + if len(versions) != 1: + raise RuntimeError(f"Expected exactly one resolved v8 version, found: {versions}") + return versions[0] + + +def default_cache_root() -> Path: + return Path(tempfile.gettempdir()) / "codex-package" + + +def load_checksums(checksums_path: Path, artifact_names: set[str]) -> dict[str, str]: + checksums: dict[str, str] = {} + lines = checksums_path.read_text(encoding="utf-8").splitlines() + if len(lines) != len(artifact_names): + raise RuntimeError( + f"Expected {len(artifact_names)} V8 checksums in {checksums_path}, " + f"found {len(lines)}." + ) + + for line in lines: + parts = line.split(maxsplit=1) + if len(parts) != 2: + raise RuntimeError(f"Invalid V8 checksum line in {checksums_path}: {line!r}") + + digest, artifact_name = parts[0], parts[1].strip() + if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest): + raise RuntimeError(f"Invalid V8 checksum digest in {checksums_path}: {digest}") + if artifact_name not in artifact_names: + raise RuntimeError( + f"Unexpected V8 checksum artifact in {checksums_path}: {artifact_name}" + ) + checksums[artifact_name] = digest + + if checksums.keys() != artifact_names: + raise RuntimeError( + f"V8 checksum manifest {checksums_path} does not cover {artifact_names}." + ) + return checksums + + +def ensure_valid_artifact(artifact: Path, checksum: str, url: str) -> None: + if has_checksum(artifact, checksum): + return + + artifact.unlink(missing_ok=True) + download_file(url, artifact) + if has_checksum(artifact, checksum): + return + + artifact.unlink(missing_ok=True) + raise RuntimeError(f"Codex-built V8 artifact {artifact} failed checksum validation.") + + +def has_checksum(path: Path, expected: str) -> bool: + if not path.is_file(): + return False + + digest = hashlib.sha256() + with path.open("rb") as artifact: + for chunk in iter(lambda: artifact.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() == expected + + +def download_file(url: str, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + temp_path = dest.with_suffix(f"{dest.suffix}.tmp") + temp_path.unlink(missing_ok=True) + try: + with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response: + with temp_path.open("wb") as output: + shutil.copyfileobj(response, output) + temp_path.replace(dest) + finally: + temp_path.unlink(missing_ok=True) diff --git a/scripts/stage_npm_packages.py b/scripts/stage_npm_packages.py index 4eb69053ebcb..d0cfccf37f20 100755 --- a/scripts/stage_npm_packages.py +++ b/scripts/stage_npm_packages.py @@ -2,20 +2,32 @@ """Stage one or more Codex npm packages for release.""" import argparse +from concurrent.futures import ThreadPoolExecutor, as_completed +from contextlib import contextmanager +from dataclasses import dataclass import importlib.util import json import os import shutil import subprocess +import tarfile import tempfile from pathlib import Path +from typing import Sequence REPO_ROOT = Path(__file__).resolve().parent.parent BUILD_SCRIPT = REPO_ROOT / "codex-cli" / "scripts" / "build_npm_package.py" -INSTALL_NATIVE_DEPS = REPO_ROOT / "codex-cli" / "scripts" / "install_native_deps.py" WORKFLOW_NAME = ".github/workflows/rust-release.yml" GITHUB_REPO = "openai/codex" +BINARY_TARGETS = ( + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-musl", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", +) _SPEC = importlib.util.spec_from_file_location("codex_build_npm_package", BUILD_SCRIPT) if _SPEC is None or _SPEC.loader is None: @@ -25,6 +37,48 @@ PACKAGE_NATIVE_COMPONENTS = getattr(_BUILD_MODULE, "PACKAGE_NATIVE_COMPONENTS", {}) PACKAGE_EXPANSIONS = getattr(_BUILD_MODULE, "PACKAGE_EXPANSIONS", {}) CODEX_PLATFORM_PACKAGES = getattr(_BUILD_MODULE, "CODEX_PLATFORM_PACKAGES", {}) +CODEX_PACKAGE_COMPONENT = getattr(_BUILD_MODULE, "CODEX_PACKAGE_COMPONENT", "codex-package") + + +@dataclass(frozen=True) +class BinaryComponent: + artifact_prefix: str + dest_dir: str + binary_basename: str + + +@dataclass(frozen=True) +class WorkflowArtifact: + name: str + size_in_bytes: int + + +BINARY_COMPONENTS = { + "codex-responses-api-proxy": BinaryComponent( + artifact_prefix="codex-responses-api-proxy", + dest_dir="codex-responses-api-proxy", + binary_basename="codex-responses-api-proxy", + ), +} + + +def _gha_enabled() -> bool: + return os.environ.get("GITHUB_ACTIONS") == "true" + + +def _gha_escape(value: str) -> str: + return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +@contextmanager +def _gha_group(title: str): + if _gha_enabled(): + print(f"::group::{_gha_escape(title)}", flush=True) + try: + yield + finally: + if _gha_enabled(): + print("::endgroup::", flush=True) def parse_args() -> argparse.Namespace: @@ -56,33 +110,23 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Retain temporary staging directories instead of deleting them.", ) - parser.add_argument( - "--allow-missing-native-component", - dest="allow_missing_native_components", - action="append", - default=[], - help=( - "Native component that may be absent from reused workflow artifacts. " - "Intended for CI compatibility only; release staging should not use this." - ), - ) - parser.add_argument( - "--allow-legacy-codex-package", - action="store_true", - help=( - "Allow codex-package layouts to be synthesized from legacy per-binary " - "workflow artifacts. Intended for CI compatibility only; release staging " - "should not use this." - ), - ) return parser.parse_args() -def collect_native_components(packages: list[str]) -> set[str]: - components: set[str] = set() +def native_components_for_package(package: str) -> tuple[str, ...]: + return tuple(sorted(PACKAGE_NATIVE_COMPONENTS.get(package, []))) + + +def collect_native_component_sets(packages: list[str]) -> list[tuple[str, ...]]: + component_sets: list[tuple[str, ...]] = [] + seen: set[tuple[str, ...]] = set() for package in packages: - components.update(PACKAGE_NATIVE_COMPONENTS.get(package, [])) - return components + components = native_components_for_package(package) + if not components or components in seen: + continue + seen.add(components) + component_sets.append(components) + return component_sets def expand_packages(packages: list[str]) -> list[str]: @@ -131,23 +175,280 @@ def install_native_components( workflow_url: str, components: set[str], vendor_root: Path, - *, - allow_legacy_codex_package: bool, + artifacts_dir: Path, ) -> None: if not components: return - cmd = [str(INSTALL_NATIVE_DEPS), "--workflow-url", workflow_url] - if allow_legacy_codex_package: - cmd.append("--allow-legacy-codex-package") - for component in sorted(components): - cmd.extend(["--component", component]) - cmd.append(str(vendor_root)) - run_command(cmd) + vendor_dir = vendor_root / "vendor" + vendor_dir.mkdir(parents=True, exist_ok=True) + + workflow_id = workflow_url.rstrip("/").split("/")[-1] + print(f"Downloading native artifacts from workflow {workflow_id}...", flush=True) + with _gha_group(f"Download native artifacts from workflow {workflow_id}"): + artifacts_dir.mkdir(parents=True, exist_ok=True) + install_from_workflow_artifacts( + workflow_id, + artifacts_dir, + sorted(components), + vendor_dir, + ) + print(f"Installed native dependencies into {vendor_dir}", flush=True) + + +def install_from_workflow_artifacts( + workflow_id: str, + artifacts_dir: Path, + components: Sequence[str], + vendor_dir: Path, +) -> None: + artifacts = select_target_artifacts(workflow_id, components) + download_artifacts(workflow_id, artifacts_dir, artifacts) + if CODEX_PACKAGE_COMPONENT in components: + install_codex_package_archives(artifacts_dir, vendor_dir, BINARY_TARGETS) + install_binary_components( + artifacts_dir, + vendor_dir, + [BINARY_COMPONENTS[name] for name in components if name in BINARY_COMPONENTS], + ) + + +def select_target_artifacts( + workflow_id: str, + components: Sequence[str], +) -> list[WorkflowArtifact]: + needs_target_artifacts = CODEX_PACKAGE_COMPONENT in components or any( + component in BINARY_COMPONENTS for component in components + ) + if not needs_target_artifacts: + return [] + + artifacts_by_name = { + artifact.name: artifact for artifact in list_workflow_artifacts(workflow_id) + } + selected_artifacts: list[WorkflowArtifact] = [] + for target in BINARY_TARGETS: + for artifact_name in [target, f"{target}-unsigned"]: + artifact = artifacts_by_name.get(artifact_name) + if artifact is not None: + selected_artifacts.append(artifact) + break + else: + raise FileNotFoundError( + f"Expected workflow artifact not found for target {target}" + ) + + return selected_artifacts + + +def list_workflow_artifacts(workflow_id: str) -> list[WorkflowArtifact]: + stdout = subprocess.check_output( + [ + "gh", + "api", + f"repos/{GITHUB_REPO}/actions/runs/{workflow_id}/artifacts", + "--paginate", + "--jq", + ".artifacts[] | [.name, .size_in_bytes] | @tsv", + ], + text=True, + ) + artifacts: list[WorkflowArtifact] = [] + for line in stdout.splitlines(): + name, size_in_bytes = line.split("\t", 1) + artifacts.append(WorkflowArtifact(name=name, size_in_bytes=int(size_in_bytes))) + return artifacts + + +def download_artifacts( + workflow_id: str, + dest_dir: Path, + artifacts: Sequence[WorkflowArtifact], +) -> None: + total_bytes = sum(artifact.size_in_bytes for artifact in artifacts) + print( + f"Downloading {len(artifacts)} artifacts ({format_bytes(total_bytes)})", + flush=True, + ) + for artifact in artifacts: + artifact_dir = dest_dir / artifact.name + if artifact_dir.is_dir() and any(artifact_dir.iterdir()): + print( + f" using cached {artifact.name} ({format_bytes(artifact.size_in_bytes)})", + flush=True, + ) + continue + + artifact_dir.mkdir(parents=True, exist_ok=True) + print( + f" downloading {artifact.name} ({format_bytes(artifact.size_in_bytes)})", + flush=True, + ) + subprocess.check_call( + [ + "gh", + "run", + "download", + "--name", + artifact.name, + "--dir", + str(artifact_dir), + "--repo", + GITHUB_REPO, + workflow_id, + ] + ) + + +def install_codex_package_archives( + artifacts_dir: Path, + vendor_dir: Path, + targets: Sequence[str], +) -> None: + if not targets: + return + + print( + "Installing Codex package archives for targets: " + ", ".join(targets), + flush=True, + ) + max_workers = min(len(targets), max(1, (os.cpu_count() or 1))) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit( + install_single_codex_package_archive, + artifacts_dir, + vendor_dir, + target, + ): target + for target in targets + } + for future in as_completed(futures): + installed_path = future.result() + print(f" installed {installed_path}", flush=True) + + +def install_single_codex_package_archive( + artifacts_dir: Path, + vendor_dir: Path, + target: str, +) -> Path: + artifact_subdir = artifact_dir_for_target(artifacts_dir, target) + archive_path = artifact_subdir / f"codex-package-{target}.tar.gz" + if not archive_path.exists(): + raise FileNotFoundError(f"Expected package archive not found: {archive_path}") + + dest_dir = vendor_dir / target + if dest_dir.exists(): + shutil.rmtree(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + + with tarfile.open(archive_path, "r:gz") as archive: + archive.extractall(dest_dir, filter="data") + + return dest_dir + + +def install_binary_components( + artifacts_dir: Path, + vendor_dir: Path, + selected_components: Sequence[BinaryComponent], +) -> None: + for component in selected_components: + component_targets = list(BINARY_TARGETS) + + print( + f"Installing {component.binary_basename} binaries for targets: " + + ", ".join(component_targets), + flush=True, + ) + max_workers = min(len(component_targets), max(1, (os.cpu_count() or 1))) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit( + install_single_binary, + artifacts_dir, + vendor_dir, + target, + component, + ): target + for target in component_targets + } + for future in as_completed(futures): + installed_path = future.result() + print(f" installed {installed_path}", flush=True) + + +def install_single_binary( + artifacts_dir: Path, + vendor_dir: Path, + target: str, + component: BinaryComponent, +) -> Path: + artifact_subdir = artifact_dir_for_target(artifacts_dir, target) + archive_path = binary_archive_path(artifact_subdir, component.artifact_prefix, target) + + dest_dir = vendor_dir / target / component.dest_dir + dest_dir.mkdir(parents=True, exist_ok=True) + + binary_name = ( + f"{component.binary_basename}.exe" if "windows" in target else component.binary_basename + ) + dest = dest_dir / binary_name + dest.unlink(missing_ok=True) + extract_zstd_archive(archive_path, dest) + if "windows" not in target: + dest.chmod(0o755) + return dest + + +def binary_archive_path(artifact_dir: Path, artifact_prefix: str, target: str) -> Path: + archive_names = [archive_name_for_target(artifact_prefix, target)] + if artifact_dir.name == f"{target}-unsigned": + archive_names.append(archive_name_for_target(artifact_prefix, f"{target}-unsigned")) + + for archive_name in archive_names: + archive_path = artifact_dir / archive_name + if archive_path.exists(): + return archive_path + + raise FileNotFoundError(f"Expected artifact not found: {artifact_dir / archive_names[0]}") + + +def archive_name_for_target(artifact_prefix: str, target: str) -> str: + if "windows" in target: + return f"{artifact_prefix}-{target}.exe.zst" + return f"{artifact_prefix}-{target}.zst" + + +def artifact_dir_for_target(artifacts_dir: Path, target: str) -> Path: + for artifact_name in [target, f"{target}-unsigned"]: + artifact_dir = artifacts_dir / artifact_name + if artifact_dir.is_dir(): + return artifact_dir + + return artifacts_dir / target + + +def extract_zstd_archive(archive_path: Path, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + + output_path = archive_path.parent / dest.name + subprocess.check_call(["zstd", "-f", "-d", str(archive_path), "-o", str(output_path)]) + shutil.move(str(output_path), dest) + + +def format_bytes(size_in_bytes: int) -> str: + value = float(size_in_bytes) + for unit in ["B", "KiB", "MiB"]: + if value < 1024: + return f"{value:.1f} {unit}" + value /= 1024 + return f"{value:.1f} GiB" def run_command(cmd: list[str]) -> None: - print("+", " ".join(cmd)) + print("+", " ".join(cmd), flush=True) subprocess.run(cmd, cwd=REPO_ROOT, check=True) @@ -167,36 +468,58 @@ def main() -> int: runner_temp = Path(os.environ.get("RUNNER_TEMP", tempfile.gettempdir())) packages = expand_packages(list(args.packages)) - native_components = collect_native_components(packages) - allow_missing_native_components = set(args.allow_missing_native_components) - native_components_to_install = native_components - allow_missing_native_components - - vendor_temp_root: Path | None = None - vendor_src: Path | None = None + native_component_sets = collect_native_component_sets(packages) + print("Expanded packages: " + ", ".join(packages), flush=True) + if native_component_sets: + component_sets = [ + "(" + ", ".join(components) + ")" for components in native_component_sets + ] + print( + "Native component sets: " + ", ".join(component_sets), + flush=True, + ) + + vendor_temp_roots: list[Path] = [] + vendor_src_by_components: dict[tuple[str, ...], Path] = {} + artifacts_temp_root: Path | None = None resolved_head_sha: str | None = None final_messages = [] try: - if native_components_to_install: + if native_component_sets: workflow_url, resolved_head_sha = resolve_workflow_url( args.release_version, args.workflow_url ) - vendor_temp_root = Path(tempfile.mkdtemp(prefix="npm-native-", dir=runner_temp)) - install_native_components( - workflow_url, - native_components_to_install, - vendor_temp_root, - allow_legacy_codex_package=args.allow_legacy_codex_package, + print(f"Using native artifacts from {workflow_url}", flush=True) + artifacts_temp_root = Path( + tempfile.mkdtemp(prefix="npm-native-artifacts-", dir=runner_temp) ) - vendor_src = vendor_temp_root / "vendor" + print(f"Caching downloaded artifacts in {artifacts_temp_root}", flush=True) + for components in native_component_sets: + vendor_temp_root = Path(tempfile.mkdtemp(prefix="npm-native-", dir=runner_temp)) + vendor_temp_roots.append(vendor_temp_root) + print( + "Installing native components " + + ", ".join(components) + + f" into {vendor_temp_root}", + flush=True, + ) + install_native_components( + workflow_url, + set(components), + vendor_temp_root, + artifacts_temp_root, + ) + vendor_src_by_components[components] = vendor_temp_root / "vendor" if resolved_head_sha: - print(f"should `git checkout {resolved_head_sha}`") + print(f"should `git checkout {resolved_head_sha}`", flush=True) for package in packages: staging_dir = Path(tempfile.mkdtemp(prefix=f"npm-stage-{package}-", dir=runner_temp)) pack_output = output_dir / tarball_name_for_package(package, args.release_version) + print(f"Staging {package} in {staging_dir}", flush=True) cmd = [ str(BUILD_SCRIPT), @@ -210,12 +533,10 @@ def main() -> int: str(pack_output), ] + vendor_src = vendor_src_by_components.get(native_components_for_package(package)) if vendor_src is not None: cmd.extend(["--vendor-src", str(vendor_src)]) - for component in sorted(allow_missing_native_components): - cmd.extend(["--allow-missing-native-component", component]) - try: run_command(cmd) finally: @@ -224,11 +545,14 @@ def main() -> int: final_messages.append(f"Staged {package} at {pack_output}") finally: - if vendor_temp_root is not None and not args.keep_staging_dirs: - shutil.rmtree(vendor_temp_root, ignore_errors=True) + if not args.keep_staging_dirs: + for vendor_temp_root in vendor_temp_roots: + shutil.rmtree(vendor_temp_root, ignore_errors=True) + if artifacts_temp_root is not None: + shutil.rmtree(artifacts_temp_root, ignore_errors=True) for msg in final_messages: - print(msg) + print(msg, flush=True) return 0 diff --git a/scripts/termux-lock-audit.sh b/scripts/termux-lock-audit.sh new file mode 100755 index 000000000000..ac40f8e57246 --- /dev/null +++ b/scripts/termux-lock-audit.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: scripts/termux-lock-audit.sh [--strict] + +Audits Rust advisory file-lock usage that may need Termux compatibility handling. + +By default this script prints findings and exits successfully. With --strict, it +exits non-zero when candidate file-lock calls are found in files that do not also +mention Unsupported/TryLockError handling. +USAGE +} + +strict=false +for arg in "$@"; do + case "$arg" in + --strict) + strict=true + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "unknown argument: $arg" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! command -v rg >/dev/null 2>&1; then + echo "error: rg is required for this audit" >&2 + exit 1 +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +unsupported_pattern='std::fs::TryLockError|fs::TryLockError|TryLockError::|ErrorKind::Unsupported|std::io::ErrorKind::Unsupported' +lock_context_pattern='(\.lock\(\)|\.try_lock\(|\.try_lock_shared\(|TryLockError|ErrorKind::Unsupported)' +candidate_file_lock_pattern='\b([A-Za-z_][A-Za-z0-9_]*_file|file)\.(lock|try_lock|try_lock_shared)\(' +candidate_false_positive_pattern='poisoned' +helper_pattern='codex_utils_file_lock|FileLockOutcome|TryFileLockOutcome|lock_exclusive_optional|try_lock_exclusive_optional|try_lock_shared_optional' + +print_section() { + printf '\n== %s ==\n' "$1" +} + +print_section "Unsupported-aware lock handling" +echo "These files already mention Unsupported/TryLockError and are likely patched or intentionally reviewed:" + +unsupported_count=0 +while IFS= read -r file; do + if rg -q "$lock_context_pattern" "$file"; then + rg -n -H "$lock_context_pattern" "$file" + unsupported_count=$((unsupported_count + 1)) + fi +done < <(rg -l "$unsupported_pattern" codex-rs -g '*.rs' || true) + +if [ "$unsupported_count" -eq 0 ]; then + echo "No unsupported-aware lock handling found." +fi + +print_section "Optional file-lock helper usage" +echo "These files use the shared optional advisory file-lock helper:" + +helper_count=0 +while IFS= read -r file; do + rg -n -H "$helper_pattern" "$file" + helper_count=$((helper_count + 1)) +done < <(rg -l "$helper_pattern" codex-rs -g '*.rs' || true) + +if [ "$helper_count" -eq 0 ]; then + echo "No optional file-lock helper usage found." +fi + +print_section "Candidate file-lock calls for manual review" +echo "These receiver names may be std::fs::File advisory locks, but the file does not mention Unsupported/TryLockError handling." +echo "This section can include false positives, such as mutexes wrapping a file." + +review_count=0 +while IFS= read -r file; do + if ! rg -q "$unsupported_pattern" "$file"; then + matches="$(rg -n -H "$candidate_file_lock_pattern" "$file" | rg -v "$candidate_false_positive_pattern" || true)" + if [ -n "$matches" ]; then + echo "$matches" + review_count=$((review_count + 1)) + fi + fi +done < <(rg -l "$candidate_file_lock_pattern" codex-rs -g '*.rs' || true) + +if [ "$review_count" -eq 0 ]; then + echo "No unpatched candidate file-lock calls found." +fi + +print_section "Candidate file-lock calls already in unsupported-aware files" +echo "These are candidate file-lock calls in files that already mention Unsupported/TryLockError handling:" + +aware_candidate_count=0 +while IFS= read -r file; do + if rg -q "$unsupported_pattern" "$file"; then + matches="$(rg -n -H "$candidate_file_lock_pattern" "$file" | rg -v "$candidate_false_positive_pattern" || true)" + if [ -n "$matches" ]; then + echo "$matches" + aware_candidate_count=$((aware_candidate_count + 1)) + fi + fi +done < <(rg -l "$candidate_file_lock_pattern" codex-rs -g '*.rs' || true) + +if [ "$aware_candidate_count" -eq 0 ]; then + echo "No unsupported-aware candidate file-lock calls found." +fi + +print_section "Summary" +echo "unsupported-aware files: $unsupported_count" +echo "optional helper files: $helper_count" +echo "review candidate files: $review_count" +echo "unsupported-aware candidate files: $aware_candidate_count" + +if [ "$strict" = true ] && [ "$review_count" -gt 0 ]; then + echo "strict mode: manual-review candidate files were found" >&2 + exit 1 +fi diff --git a/scripts/test-remote-env.sh b/scripts/test-remote-env.sh index 96743616a211..584a0f6f291a 100755 --- a/scripts/test-remote-env.sh +++ b/scripts/test-remote-env.sh @@ -5,7 +5,7 @@ # Usage (source-only): # source scripts/test-remote-env.sh # cd codex-rs -# cargo test -p codex-core --test all remote_env_connects_creates_temp_dir_and_runs_sample_script +# just test -p codex-core --test all remote_test_env_can_connect_and_use_filesystem # codex_remote_env_cleanup SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/third_party/v8/README.md b/third_party/v8/README.md index 336d03c74b8e..8b512ecb19dc 100644 --- a/third_party/v8/README.md +++ b/third_party/v8/README.md @@ -100,11 +100,11 @@ hermetic Windows C++ platform is `windows-gnullvm`/`x86_64-w64-windows-gnu`, so it cannot truthfully reproduce upstream's `*-pc-windows-msvc` archives until we add a real MSVC-targeting C++ toolchain to the Bazel graph. -Cargo musl builds use `RUSTY_V8_ARCHIVE` plus a downloaded -`RUSTY_V8_SRC_BINDING_PATH` to point at those `openai/codex` release assets -directly. We do not use `RUSTY_V8_MIRROR` for musl because the upstream `v8` -crate hardcodes a `v` tag layout, while our musl artifacts are -published under `rusty-v8-v`. +Release and CI Cargo builds for Darwin and Linux use `RUSTY_V8_ARCHIVE` plus a +downloaded `RUSTY_V8_SRC_BINDING_PATH` to point at those `openai/codex` release +assets directly. We do not use `RUSTY_V8_MIRROR` because the upstream `v8` crate +hardcodes a `v` tag layout, while our artifacts are published +under `rusty-v8-v`. Do not mix artifacts across crate versions. The archive and binding must match the exact resolved `v8` crate version in `codex-rs/Cargo.lock`.