diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index f67f1983f2..4595a1fa65 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -8,4 +8,4 @@ script = "" [[actions]] name = "Run" icon = "run" -command = "cargo +1.93.0 run --manifest-path=codex-rs/Cargo.toml --bin codex -- -c mcp_oauth_credentials_store=file" +command = "cargo +1.95.0 run --manifest-path=codex-rs/Cargo.toml --bin codex -- -c mcp_oauth_credentials_store=file" diff --git a/.devcontainer/Dockerfile.secure b/.devcontainer/Dockerfile.secure index 6c1878eafa..83af317d8b 100644 --- a/.devcontainer/Dockerfile.secure +++ b/.devcontainer/Dockerfile.secure @@ -3,7 +3,7 @@ FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 ARG TZ ARG DEBIAN_FRONTEND=noninteractive ARG NODE_MAJOR=22 -ARG RUST_TOOLCHAIN=1.92.0 +ARG RUST_TOOLCHAIN=1.95.0 # Keep this in sync with .devcontainer/codex-install/package.json and pnpm-lock.yaml. ARG CODEX_NPM_VERSION=0.121.0 diff --git a/.devcontainer/devcontainer.secure.json b/.devcontainer/devcontainer.secure.json index 5d5808e541..7b05fb9a6c 100644 --- a/.devcontainer/devcontainer.secure.json +++ b/.devcontainer/devcontainer.secure.json @@ -7,7 +7,7 @@ "args": { "TZ": "${localEnv:TZ:UTC}", "NODE_MAJOR": "22", - "RUST_TOOLCHAIN": "1.92.0", + "RUST_TOOLCHAIN": "1.95.0", "CODEX_NPM_VERSION": "0.121.0" } }, diff --git a/.github/scripts/install-musl-build-tools.sh b/.github/scripts/install-musl-build-tools.sh index e4c6683d0e..49035f5391 100644 --- a/.github/scripts/install-musl-build-tools.sh +++ b/.github/scripts/install-musl-build-tools.sh @@ -150,7 +150,9 @@ for arg in "\$@"; do args+=("\${arg}") done -exec "${zig_bin}" cc -target "${zig_target}" "\${args[@]}" +# Zig enables UBSan for debug C builds by default. Rust links these objects +# without Zig's sanitizer runtime, so keep native dependencies uninstrumented. +exec "${zig_bin}" cc -target "${zig_target}" "\${args[@]}" -fno-sanitize=undefined EOF cat >"${cxx}" <> "$GITHUB_ENV" pkg_config_path_var="PKG_CONFIG_PATH_${TARGET}" pkg_config_path_var="${pkg_config_path_var//-/_}" echo "${pkg_config_path_var}=${libcap_pkgconfig_dir}" >> "$GITHUB_ENV" +pkg_config_libdir_var="PKG_CONFIG_LIBDIR_${TARGET}" +pkg_config_libdir_var="${pkg_config_libdir_var//-/_}" +# Do not let musl cross-builds resolve native libraries from the host glibc +# pkg-config directories. libcap is the only target package provided here. +echo "${pkg_config_libdir_var}=${libcap_pkgconfig_dir}" >> "$GITHUB_ENV" if [[ -n "${sysroot}" && "${sysroot}" != "/" ]]; then echo "PKG_CONFIG_SYSROOT_DIR=${sysroot}" >> "$GITHUB_ENV" diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 11c0988ceb..153ace0fc9 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -141,7 +141,9 @@ jobs: - 2 - 3 - 4 - runs-on: windows-latest + runs-on: + group: codex-runners + labels: codex-windows-x64 name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm shard ${{ matrix.shard }}/4 steps: @@ -246,7 +248,9 @@ jobs: # it a larger timeout. if: github.event_name == 'push' && github.ref == 'refs/heads/main' timeout-minutes: 40 - runs-on: windows-latest + runs-on: + group: codex-runners + labels: codex-windows-x64 name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm (native main) steps: @@ -332,7 +336,10 @@ jobs: target: aarch64-apple-darwin - os: windows-latest target: x86_64-pc-windows-gnullvm - runs-on: ${{ matrix.os }} + runs_on: + group: codex-runners + labels: codex-windows-x64 + runs-on: ${{ matrix.runs_on || matrix.os }} name: Bazel clippy on ${{ matrix.os }} for ${{ matrix.target }} steps: @@ -422,7 +429,10 @@ jobs: target: aarch64-apple-darwin - os: windows-latest target: x86_64-pc-windows-gnullvm - runs-on: ${{ matrix.os }} + runs_on: + group: codex-runners + labels: codex-windows-x64 + runs-on: ${{ matrix.runs_on || matrix.os }} name: Verify release build on ${{ matrix.os }} for ${{ matrix.target }} steps: diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index f20d09e112..656c50ef28 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -20,10 +20,10 @@ jobs: persist-credentials: false - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - name: Run cargo-deny uses: EmbarkStudios/cargo-deny-action@82eb9f621fbc699dd0918f3ea06864c14cc84246 # v2 with: - rust-version: 1.93.0 + rust-version: 1.95.0 manifest-path: ./codex-rs/Cargo.toml diff --git a/.github/workflows/python-sdk-release.yml b/.github/workflows/python-sdk-release.yml new file mode 100644 index 0000000000..654dd8d822 --- /dev/null +++ b/.github/workflows/python-sdk-release.yml @@ -0,0 +1,91 @@ +name: python-sdk-release + +on: + push: + tags: + - "python-v*" + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + build-python-sdk: + if: github.repository == 'openai/codex' + name: build-python-sdk + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Validate tag and build Python SDK package + shell: bash + run: | + set -euo pipefail + + sdk_version="${GITHUB_REF_NAME#python-v}" + if [[ ! "${sdk_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+b[0-9]+$ ]]; then + echo "Python SDK release tags must identify a beta release, for example python-v0.1.0b1." + exit 1 + fi + + # The pinned runtime currently publishes a musllinux Linux wheel. + # Build in Alpine so release type generation installs that wheel. + docker run --rm \ + --user "$(id -u):$(id -g)" \ + -e HOME=/tmp/codex-python-sdk-home \ + -e UV_LINK_MODE=copy \ + -e SDK_VERSION="${sdk_version}" \ + -e SDK_STAGE_DIR="${RUNNER_TEMP}/openai-codex" \ + -e SDK_DIST_DIR="${GITHUB_WORKSPACE}/dist/python-sdk" \ + -v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ + -v "${RUNNER_TEMP}:${RUNNER_TEMP}" \ + -w "${GITHUB_WORKSPACE}/sdk/python" \ + python:3.12-alpine \ + sh -euxc ' + python -m venv /tmp/release-tools + /tmp/release-tools/bin/python -m pip install build twine uv==0.11.3 + /tmp/release-tools/bin/uv sync --extra dev --frozen + /tmp/release-tools/bin/uv run --extra dev --frozen python scripts/update_sdk_artifacts.py \ + stage-sdk "${SDK_STAGE_DIR}" \ + --sdk-version "${SDK_VERSION}" + /tmp/release-tools/bin/python -m build \ + --wheel \ + --sdist \ + --outdir "${SDK_DIST_DIR}" \ + "${SDK_STAGE_DIR}" + /tmp/release-tools/bin/python -m twine check --strict "${SDK_DIST_DIR}/"* + ' + + - name: Upload Python SDK package + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: python-sdk-package + path: dist/python-sdk/* + if-no-files-found: error + + publish-python-sdk: + name: publish-python-sdk + needs: build-python-sdk + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write # Required for PyPI trusted publishing. + + steps: + - name: Download Python SDK package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-sdk-package + path: dist/python-sdk + + - name: Publish Python SDK to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + packages-dir: dist/python-sdk diff --git a/.github/workflows/rust-ci-full-nextest-platform.yml b/.github/workflows/rust-ci-full-nextest-platform.yml index 7dc39d33ed..3fdf7b51ee 100644 --- a/.github/workflows/rust-ci-full-nextest-platform.yml +++ b/.github/workflows/rust-ci-full-nextest-platform.yml @@ -94,7 +94,7 @@ jobs: - name: Install DotSlash uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ inputs.target }} @@ -319,7 +319,7 @@ jobs: - name: Install DotSlash uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ inputs.target }} diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index c4a9329c00..60d508b21d 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: components: rustfmt - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 @@ -46,7 +46,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: tool: cargo-shear@1.11.2 @@ -63,7 +63,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: toolchain: nightly-2025-09-18 components: llvm-tools-preview, rustc-dev, rust-src @@ -260,13 +260,9 @@ jobs: set -euo pipefail if command -v apt-get >/dev/null 2>&1; then sudo apt-get update -y - packages=(pkg-config libcap-dev) - if [[ "${{ matrix.target }}" == 'x86_64-unknown-linux-musl' || "${{ matrix.target }}" == 'aarch64-unknown-linux-musl' ]]; then - packages+=(libubsan1) - fi - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${packages[@]}" + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev fi - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ matrix.target }} components: clippy @@ -348,14 +344,6 @@ jobs: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}- sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Disable sccache wrapper (musl) - shell: bash - run: | - set -euo pipefail - echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV" - echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Prepare APT cache directories (musl) shell: bash @@ -389,58 +377,6 @@ jobs: shell: bash run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Configure rustc UBSan wrapper (musl host) - shell: bash - run: | - set -euo pipefail - ubsan="" - if command -v ldconfig >/dev/null 2>&1; then - ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" - fi - wrapper_root="${RUNNER_TEMP:-/tmp}" - wrapper="${wrapper_root}/rustc-ubsan-wrapper" - cat > "${wrapper}" <> "$GITHUB_ENV" - echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Clear sanitizer flags (musl) - shell: bash - run: | - set -euo pipefail - # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. - echo "RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" - # Override any runner-level Cargo config rustflags as well. - echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" - - sanitize_flags() { - local input="$1" - input="${input//-fsanitize=undefined/}" - input="${input//-fno-sanitize-recover=undefined/}" - input="${input//-fno-sanitize-trap=undefined/}" - echo "$input" - } - - cflags="$(sanitize_flags "${CFLAGS-}")" - cxxflags="$(sanitize_flags "${CXXFLAGS-}")" - echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" - echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - if: ${{ !contains(matrix.target, 'windows') }} name: Configure rusty_v8 artifact overrides and verify checksums uses: ./.github/actions/setup-rusty-v8 diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 029b6f2c7e..d1d7c0df96 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -67,7 +67,7 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: components: rustfmt - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 @@ -91,7 +91,7 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: tool: cargo-shear@1.11.2 @@ -111,7 +111,7 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - name: Install nightly argument-comment-lint toolchain shell: bash run: | diff --git a/.github/workflows/rust-release-argument-comment-lint.yml b/.github/workflows/rust-release-argument-comment-lint.yml index f654bd9dd7..94f15af7d4 100644 --- a/.github/workflows/rust-release-argument-comment-lint.yml +++ b/.github/workflows/rust-release-argument-comment-lint.yml @@ -60,7 +60,7 @@ jobs: with: persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: toolchain: nightly-2025-09-18 targets: ${{ matrix.target }} diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 51412be0e0..088cb8a74c 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -100,7 +100,7 @@ jobs: Write-Host "Total RAM: $ramGiB GiB" Write-Host "Disk usage:" Get-PSDrive -PSProvider FileSystem | Format-Table -AutoSize Name, @{Name='Size(GB)';Expression={[math]::Round(($_.Used + $_.Free) / 1GB, 1)}}, @{Name='Free(GB)';Expression={[math]::Round($_.Free / 1GB, 1)}} - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ matrix.target }} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 2cd11e66fe..07a2e87ef2 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -56,7 +56,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - name: Validate tag matches Cargo.toml version shell: bash env: @@ -245,16 +245,7 @@ jobs: set -euo pipefail sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - name: Install UBSan runtime (musl) - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - shell: bash - run: | - set -euo pipefail - if command -v apt-get >/dev/null 2>&1; then - sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 - fi - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ matrix.target }} @@ -283,30 +274,7 @@ jobs: run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Configure rustc UBSan wrapper (musl host) - shell: bash - run: | - set -euo pipefail - ubsan="" - if command -v ldconfig >/dev/null 2>&1; then - ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" - fi - wrapper_root="${RUNNER_TEMP:-/tmp}" - wrapper="${wrapper_root}/rustc-ubsan-wrapper" - cat > "${wrapper}" <> "$GITHUB_ENV" - echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Clear sanitizer flags (musl) + name: Disable aws-lc jitter entropy (musl) shell: bash run: | set -euo pipefail @@ -316,30 +284,6 @@ jobs: target_no_jitter="${target_no_jitter//-/_}" echo "${target_no_jitter}=1" >> "$GITHUB_ENV" - # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. - echo "RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" - # Override any runner-level Cargo config rustflags as well. - echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" - - sanitize_flags() { - local input="$1" - input="${input//-fsanitize=undefined/}" - input="${input//-fno-sanitize-recover=undefined/}" - input="${input//-fno-sanitize-trap=undefined/}" - echo "$input" - } - - cflags="$(sanitize_flags "${CFLAGS-}")" - cxxflags="$(sanitize_flags "${CXXFLAGS-}")" - echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" - echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - name: Configure rusty_v8 artifact overrides and verify checksums uses: ./.github/actions/setup-rusty-v8 with: @@ -1282,19 +1226,6 @@ jobs: tag: ${{ github.ref_name }} config: .github/dotslash-argument-comment-lint-config.json - - name: Trigger developers.openai.com deploy - # Only trigger the deploy if the release is not a pre-release. - # The deploy is used to update the developers.openai.com website with the new config schema json file. - if: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} - continue-on-error: true - env: - DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} - run: | - if ! curl -sS -f -o /dev/null -X POST "$DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL"; then - echo "::warning title=developers.openai.com deploy hook failed::Vercel deploy hook POST failed for ${GITHUB_REF_NAME}" - exit 1 - fi - # Publish to npm using OIDC authentication. # July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/ # npm docs: https://docs.npmjs.com/trusted-publishers @@ -1507,6 +1438,36 @@ jobs: packages-dir: dist/python-runtime skip-existing: true + deploy-dev-website: + name: Trigger developers.openai.com deploy + needs: release + # Only trigger the deploy for a stable signed release. + # The deploy updates developers.openai.com with the new config schema json file. + if: >- + ${{ + !cancelled() && + needs.release.result == 'success' && + needs.release.outputs.sign_macos == 'true' && + !contains(needs.release.outputs.version, '-') + }} + runs-on: ubuntu-latest + continue-on-error: true + permissions: {} + environment: + name: dev-website-vercel-deploy + deployment: false + + steps: + - name: Trigger developers.openai.com deploy + continue-on-error: true + env: + DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} + run: | + if ! curl -sS -f -o /dev/null -X POST "$DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL"; then + echo "::warning title=developers.openai.com deploy hook failed::Vercel deploy hook POST failed for ${GITHUB_REF_NAME}" + exit 1 + fi + winget: name: winget needs: release diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index 3b56a1e93f..d98cdb2c43 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -152,9 +152,9 @@ jobs: python-version: "3.12" - name: Set up Rust toolchain for Cargo smoke - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: - toolchain: "1.93.0" + toolchain: "1.95.0" - name: Build Bazel V8 release pair env: diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 979e991504..6341f218a6 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -166,9 +166,9 @@ jobs: python-version: "3.12" - name: Set up Rust toolchain for Cargo smoke - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: - toolchain: "1.93.0" + toolchain: "1.95.0" - name: Build Bazel V8 release pair env: @@ -310,9 +310,9 @@ jobs: architecture: x64 - name: Set up Codex Rust toolchain for Cargo smoke - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: - toolchain: "1.93.0" + toolchain: "1.95.0" targets: ${{ matrix.target }} - name: Install rusty_v8 Rust toolchain @@ -401,7 +401,7 @@ jobs: cd codex-rs RUSTY_V8_ARCHIVE="${GITHUB_WORKSPACE}/${archive}" \ RUSTY_V8_SRC_BINDING_PATH="${GITHUB_WORKSPACE}/${binding}" \ - cargo +1.93.0 test -p codex-v8-poc --target "${TARGET}" --features sandbox --no-run + cargo +1.95.0 test -p codex-v8-poc --target "${TARGET}" --features sandbox --no-run ) - name: Upload staged artifacts diff --git a/MODULE.bazel b/MODULE.bazel index 81f16b7b9e..abca889d64 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -163,7 +163,7 @@ use_repo(nightly_rust, "rust_toolchains") toolchains = use_extension("@rules_rs//rs/experimental/toolchains:module_extension.bzl", "toolchains") toolchains.toolchain( edition = "2024", - version = "1.93.0", + version = "1.95.0", ) use_repo(toolchains, "default_rust_toolchains") diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index ad56196dd9..f541a2c7bf 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -704,6 +704,7 @@ "bitflags_2.11.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"arbitrary\",\"req\":\"^1.0\"},{\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.12\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.12.2\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde_lib\",\"package\":\"serde\",\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.19\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.18\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"zerocopy\",\"req\":\"^0.8\"}],\"features\":{\"example_generated\":[],\"serde\":[\"serde_core\"],\"std\":[]}}", "blake2_0.10.6": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.10.3\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"}],\"features\":{\"default\":[\"std\"],\"reset\":[],\"simd\":[],\"simd_asm\":[\"simd_opt\"],\"simd_opt\":[\"simd\"],\"size_opt\":[],\"std\":[\"digest/std\"]}}", "block-buffer_0.10.4": "{\"dependencies\":[{\"name\":\"generic-array\",\"req\":\"^0.14\"}],\"features\":{}}", + "block-buffer_0.12.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"hybrid-array\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{}}", "block-padding_0.3.3": "{\"dependencies\":[{\"name\":\"generic-array\",\"req\":\"^0.14\"}],\"features\":{\"std\":[]}}", "block2_0.6.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"objc2\",\"req\":\">=0.6.2, <0.8.0\"}],\"features\":{\"alloc\":[],\"compiler-rt\":[\"objc2/unstable-compiler-rt\"],\"default\":[\"std\"],\"gnustep-1-7\":[\"objc2/gnustep-1-7\"],\"gnustep-1-8\":[\"gnustep-1-7\",\"objc2/gnustep-1-8\"],\"gnustep-1-9\":[\"gnustep-1-8\",\"objc2/gnustep-1-9\"],\"gnustep-2-0\":[\"gnustep-1-9\",\"objc2/gnustep-2-0\"],\"gnustep-2-1\":[\"gnustep-2-0\",\"objc2/gnustep-2-1\"],\"std\":[\"alloc\"],\"unstable-coerce-pointee\":[],\"unstable-objfw\":[],\"unstable-private\":[],\"unstable-winobjc\":[\"gnustep-1-8\"]}}", "blocking_1.6.2": "{\"dependencies\":[{\"name\":\"async-channel\",\"req\":\"^2.0.0\"},{\"name\":\"async-task\",\"req\":\"^4.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-io\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"name\":\"piper\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.37\"}],\"features\":{}}", @@ -738,6 +739,7 @@ "cfg-if_1.0.4": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"rustc-dep-of-std\":[\"core\"]}}", "cfg_aliases_0.1.1": "{\"dependencies\":[],\"features\":{}}", "cfg_aliases_0.2.1": "{\"dependencies\":[],\"features\":{}}", + "chacha20_0.10.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"features\":[\"stream-wrapper\"],\"name\":\"cipher\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.5\"},{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8.1\"}],\"features\":{\"default\":[\"cipher\"],\"legacy\":[\"cipher\"],\"rng\":[\"dep:rand_core\"],\"xchacha\":[\"cipher\"]}}", "chacha20_0.9.1": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cipher\",\"req\":\"^0.4.4\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.4.4\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3.3\"}],\"features\":{\"std\":[\"cipher/std\"],\"zeroize\":[\"cipher/zeroize\"]}}", "chacha20poly1305_0.10.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aead\",\"req\":\"^0.5\"},{\"default_features\":false,\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"aead\",\"req\":\"^0.5\"},{\"features\":[\"zeroize\"],\"name\":\"chacha20\",\"req\":\"^0.9\"},{\"name\":\"cipher\",\"req\":\"^0.4\"},{\"name\":\"poly1305\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1.5\"}],\"features\":{\"alloc\":[\"aead/alloc\"],\"default\":[\"alloc\",\"getrandom\"],\"getrandom\":[\"aead/getrandom\",\"rand_core\"],\"heapless\":[\"aead/heapless\"],\"rand_core\":[\"aead/rand_core\"],\"reduced-round\":[],\"std\":[\"aead/std\",\"alloc\"],\"stream\":[\"aead/stream\"]}}", "chardetng_0.1.17": "{\"dependencies\":[{\"name\":\"arrayvec\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"detone\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"encoding_rs\",\"req\":\"^0.8.29\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.2.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"multithreading\":[\"rayon\",\"arrayvec\"],\"testing-only-no-semver-guarantees-do-not-use\":[]}}", @@ -757,6 +759,7 @@ "clipboard-win_5.4.1": "{\"dependencies\":[{\"name\":\"error-code\",\"req\":\"^3\",\"target\":\"cfg(windows)\"},{\"name\":\"windows-win\",\"optional\":true,\"req\":\"^3\",\"target\":\"cfg(windows)\"}],\"features\":{\"monitor\":[\"windows-win\"],\"std\":[\"error-code/std\"]}}", "clru_0.6.3": "{\"dependencies\":[{\"name\":\"hashbrown\",\"req\":\"^0.16\"}],\"features\":{}}", "cmake_0.1.57": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.2.46\"}],\"features\":{}}", + "cmov_0.5.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.9\",\"target\":\"cfg(any(unix, windows))\"}],\"features\":{}}", "cmp_any_0.8.1": "{\"dependencies\":[],\"features\":{}}", "codespan-reporting_0.13.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.6.3\"},{\"kind\":\"dev\",\"name\":\"peg\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"pico-args\",\"req\":\"^0.5.0\"},{\"kind\":\"dev\",\"name\":\"rustyline\",\"req\":\"^6\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1.0.4\"},{\"name\":\"unicode-width\",\"req\":\">=0.1, <0.3\"},{\"kind\":\"dev\",\"name\":\"unindent\",\"req\":\"^0.1\"}],\"features\":{\"ascii-only\":[],\"default\":[\"std\",\"termcolor\"],\"serialization\":[\"serde\"],\"std\":[\"serde?/std\"],\"termcolor\":[\"std\",\"dep:termcolor\"]}}", "color-eyre_0.6.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ansi-parser\",\"req\":\"^0.8.0\"},{\"name\":\"backtrace\",\"req\":\"^0.3.59\"},{\"name\":\"color-spantrace\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"eyre\",\"req\":\"^0.6\"},{\"name\":\"indenter\",\"req\":\"^0.3.0\"},{\"name\":\"once_cell\",\"req\":\"^1.18.0\"},{\"name\":\"owo-colors\",\"req\":\"^4.0\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1.0.19\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.13\"},{\"name\":\"tracing-error\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.1.1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.15\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"}],\"features\":{\"capture-spantrace\":[\"tracing-error\",\"color-spantrace\"],\"default\":[\"track-caller\",\"capture-spantrace\"],\"issue-url\":[\"url\"],\"track-caller\":[]}}", @@ -789,6 +792,7 @@ "coreaudio-sys_0.2.17": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"req\":\"^0.72\"}],\"features\":{\"audio_server_plugin\":[],\"audio_toolbox\":[],\"audio_unit\":[],\"core_audio\":[],\"core_midi\":[],\"default\":[\"audio_toolbox\",\"audio_unit\",\"core_audio\",\"audio_server_plugin\",\"open_al\",\"core_midi\"],\"io_kit_audio\":[],\"open_al\":[]}}", "cpal_0.15.3": "{\"dependencies\":[{\"name\":\"alsa\",\"req\":\"^0.9\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\"))\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"asio-sys\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.0\"},{\"name\":\"core-foundation-sys\",\"req\":\"^0.8.2\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"ios\\\"))\"},{\"default_features\":false,\"features\":[\"audio_unit\",\"core_audio\",\"audio_toolbox\"],\"name\":\"coreaudio-rs\",\"req\":\"^0.11\",\"target\":\"cfg(target_os = \\\"ios\\\")\"},{\"default_features\":false,\"features\":[\"audio_unit\",\"core_audio\"],\"name\":\"coreaudio-rs\",\"req\":\"^0.11\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"name\":\"dasp_sample\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hound\",\"req\":\"^3.5\"},{\"name\":\"jack\",\"optional\":true,\"req\":\"^0.11\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\"))\"},{\"name\":\"jni\",\"req\":\"^0.21\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"js-sys\",\"req\":\"^0.3.35\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.35\",\"target\":\"cfg(target_os = \\\"emscripten\\\")\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\"))\"},{\"name\":\"mach2\",\"req\":\"^0.4\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"ios\\\"))\"},{\"default_features\":false,\"name\":\"ndk\",\"req\":\"^0.8\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"ndk-context\",\"req\":\"^0.1\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"ndk-glue\",\"req\":\"^0.7\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"num-traits\",\"optional\":true,\"req\":\"^0.2.6\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"features\":[\"java-interface\"],\"name\":\"oboe\",\"req\":\"^0.6\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"ringbuf\",\"req\":\"^0.3\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.58\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_os = \\\"emscripten\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.33\",\"target\":\"cfg(target_os = \\\"emscripten\\\")\"},{\"features\":[\"AudioContext\",\"AudioContextOptions\",\"AudioBuffer\",\"AudioBufferSourceNode\",\"AudioNode\",\"AudioDestinationNode\",\"Window\",\"AudioContextState\"],\"name\":\"web-sys\",\"req\":\"^0.3.35\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"AudioContext\",\"AudioContextOptions\",\"AudioBuffer\",\"AudioBufferSourceNode\",\"AudioNode\",\"AudioDestinationNode\",\"Window\",\"AudioContextState\"],\"name\":\"web-sys\",\"req\":\"^0.3.35\",\"target\":\"cfg(target_os = \\\"emscripten\\\")\"},{\"features\":[\"Win32_Media_Audio\",\"Win32_Foundation\",\"Win32_Devices_Properties\",\"Win32_Media_KernelStreaming\",\"Win32_System_Com_StructuredStorage\",\"Win32_System_Threading\",\"Win32_Security\",\"Win32_System_SystemServices\",\"Win32_System_Variant\",\"Win32_Media_Multimedia\",\"Win32_UI_Shell_PropertiesSystem\"],\"name\":\"windows\",\"req\":\"^0.54.0\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"asio\":[\"asio-sys\",\"num-traits\"],\"oboe-shared-stdcxx\":[\"oboe/shared-stdcxx\"]}}", "cpufeatures_0.2.17": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"aarch64-linux-android\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_os = \\\"linux\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_vendor = \\\"apple\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"loongarch64\\\", target_os = \\\"linux\\\"))\"}],\"features\":{}}", + "cpufeatures_0.3.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_os = \\\"android\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_os = \\\"linux\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_vendor = \\\"apple\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"loongarch64\\\", target_os = \\\"linux\\\"))\"}],\"features\":{}}", "crc-catalog_2.4.0": "{\"dependencies\":[],\"features\":{}}", "crc32fast_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"std\"],\"nightly\":[],\"std\":[]}}", "crc_3.4.0": "{\"dependencies\":[{\"name\":\"crc-catalog\",\"req\":\"^2.4.0\"}],\"features\":{}}", @@ -802,6 +806,7 @@ "crunchy_0.2.4": "{\"dependencies\":[],\"features\":{\"default\":[\"limit_128\"],\"limit_1024\":[],\"limit_128\":[],\"limit_2048\":[],\"limit_256\":[],\"limit_512\":[],\"limit_64\":[],\"std\":[]}}", "crypto-bigint_0.5.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"der\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"generic-array\",\"optional\":true,\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6.4\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"rlp\",\"optional\":true,\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.4\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"serdect?/alloc\"],\"default\":[\"rand\"],\"extra-sizes\":[],\"rand\":[\"rand_core/std\"],\"serde\":[\"dep:serdect\"]}}", "crypto-common_0.1.7": "{\"dependencies\":[{\"features\":[\"more_lengths\"],\"name\":\"generic-array\",\"req\":\"=0.14.7\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"typenum\",\"req\":\"^1.14\"}],\"features\":{\"getrandom\":[\"rand_core/getrandom\"],\"std\":[]}}", + "crypto-common_0.2.2": "{\"dependencies\":[{\"features\":[\"sys_rng\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"hybrid-array\",\"req\":\"^0.4.7\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"}],\"features\":{\"getrandom\":[\"rand_core\",\"dep:getrandom\"],\"rand_core\":[\"dep:rand_core\"],\"zeroize\":[\"hybrid-array/zeroize\"]}}", "crypto_box_0.9.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aead\",\"req\":\"^0.5.2\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"blake2\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"chacha20\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"crypto_secretbox\",\"req\":\"^0.1.1\"},{\"default_features\":false,\"features\":[\"zeroize\"],\"name\":\"curve25519-dalek\",\"req\":\"^4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rmp-serde\",\"req\":\"^1\"},{\"name\":\"salsa20\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"aead/alloc\"],\"chacha20\":[\"dep:chacha20\",\"crypto_secretbox/chacha20\"],\"default\":[\"alloc\",\"getrandom\",\"salsa20\"],\"getrandom\":[\"aead/getrandom\",\"rand_core\"],\"heapless\":[\"aead/heapless\"],\"rand_core\":[\"aead/rand_core\"],\"salsa20\":[\"dep:salsa20\",\"crypto_secretbox/salsa20\"],\"seal\":[\"dep:blake2\",\"alloc\"],\"serde\":[\"dep:serdect\"],\"std\":[\"aead/std\"]}}", "crypto_secretbox_0.1.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aead\",\"req\":\"^0.5\"},{\"features\":[\"zeroize\"],\"name\":\"chacha20\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"cipher\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"zeroize\"],\"name\":\"generic-array\",\"req\":\"^0.14.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"name\":\"poly1305\",\"req\":\"^0.8\"},{\"features\":[\"zeroize\"],\"name\":\"salsa20\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"aead/alloc\"],\"default\":[\"alloc\",\"getrandom\",\"salsa20\"],\"getrandom\":[\"aead/getrandom\",\"rand_core\"],\"heapless\":[\"aead/heapless\"],\"rand_core\":[\"aead/rand_core\"],\"std\":[\"aead/std\",\"alloc\"],\"stream\":[\"aead/stream\"]}}", "csv-core_0.1.13": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"arrayvec\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2\"}],\"features\":{\"default\":[],\"libc\":[\"memchr/libc\"]}}", @@ -809,6 +814,7 @@ "ctor-proc-macro_0.0.7": "{\"dependencies\":[],\"features\":{\"default\":[]}}", "ctor_0.1.26": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"},{\"name\":\"quote\",\"req\":\"^1.0.20\"},{\"default_features\":false,\"features\":[\"full\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^1.0.98\"}],\"features\":{}}", "ctor_0.6.3": "{\"dependencies\":[{\"name\":\"ctor-proc-macro\",\"optional\":true,\"req\":\"=0.0.7\"},{\"default_features\":false,\"name\":\"dtor\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"}],\"features\":{\"__no_warn_on_missing_unsafe\":[\"dtor?/__no_warn_on_missing_unsafe\"],\"default\":[\"dtor\",\"proc_macro\",\"__no_warn_on_missing_unsafe\"],\"dtor\":[\"dep:dtor\"],\"proc_macro\":[\"dep:ctor-proc-macro\",\"dtor?/proc_macro\"],\"used_linker\":[\"dtor?/used_linker\"]}}", + "ctutils_0.4.2": "{\"dependencies\":[{\"name\":\"cmov\",\"req\":\"^0.5.3\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.11\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"}],\"features\":{\"alloc\":[],\"subtle\":[\"dep:subtle\"]}}", "curve25519-dalek-derive_0.1.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.66\"},{\"name\":\"quote\",\"req\":\"^1.0.31\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.27\"}],\"features\":{}}", "curve25519-dalek_4.1.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2.6\",\"target\":\"cfg(target_arch = \\\"x86_64\\\")\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"curve25519-dalek-derive\",\"req\":\"^0.1\",\"target\":\"cfg(all(not(curve25519_dalek_backend = \\\"fiat\\\"), not(curve25519_dalek_backend = \\\"serial\\\"), target_arch = \\\"x86_64\\\"))\"},{\"default_features\":false,\"name\":\"digest\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"ff\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"name\":\"fiat-crypto\",\"req\":\"^0.2.1\",\"target\":\"cfg(curve25519_dalek_backend = \\\"fiat\\\")\"},{\"default_features\":false,\"name\":\"group\",\"optional\":true,\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6.4\"},{\"default_features\":false,\"features\":[\"getrandom\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"zeroize?/alloc\"],\"default\":[\"alloc\",\"precomputed-tables\",\"zeroize\"],\"group\":[\"dep:group\",\"rand_core\"],\"group-bits\":[\"group\",\"ff/bits\"],\"legacy_compatibility\":[],\"precomputed-tables\":[]}}", "cxx-build_1.0.194": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.0.101\"},{\"name\":\"codespan-reporting\",\"req\":\"^0.13.1\"},{\"kind\":\"dev\",\"name\":\"cxx\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"cxx-gen\",\"req\":\"^0.7\"},{\"name\":\"indexmap\",\"req\":\"^2.9.0\"},{\"kind\":\"dev\",\"name\":\"pkg-config\",\"req\":\"^0.3.27\"},{\"default_features\":false,\"features\":[\"span-locations\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"name\":\"scratch\",\"req\":\"^1.0.5\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"full\",\"parsing\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.46\"}],\"features\":{\"parallel\":[\"cc/parallel\"]}}", @@ -850,6 +856,7 @@ "difflib_0.4.0": "{\"dependencies\":[],\"features\":{}}", "diffy_0.4.2": "{\"dependencies\":[{\"name\":\"nu-ansi-term\",\"req\":\"^0.50\"}],\"features\":{}}", "digest_0.10.7": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"block-buffer\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"const-oid\",\"optional\":true,\"req\":\"^0.9\"},{\"name\":\"crypto-common\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2.4\"}],\"features\":{\"alloc\":[],\"core-api\":[\"block-buffer\"],\"default\":[\"core-api\"],\"dev\":[\"blobby\"],\"mac\":[\"subtle\"],\"oid\":[\"const-oid\"],\"rand_core\":[\"crypto-common/rand_core\"],\"std\":[\"alloc\",\"crypto-common/std\"]}}", + "digest_0.11.3": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"block-buffer\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"common\",\"package\":\"crypto-common\",\"req\":\"^0.2\"},{\"name\":\"const-oid\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"ctutils\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.7\"}],\"features\":{\"alloc\":[],\"block-api\":[\"dep:block-buffer\"],\"default\":[\"block-api\"],\"dev\":[\"blobby\"],\"getrandom\":[\"common/getrandom\",\"rand_core\"],\"mac\":[\"dep:ctutils\"],\"oid\":[\"dep:const-oid\"],\"rand_core\":[\"common/rand_core\"],\"zeroize\":[\"dep:zeroize\",\"block-buffer?/zeroize\"]}}", "diplomat-runtime_0.15.1": "{\"dependencies\":[{\"name\":\"jni\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"}],\"features\":{\"jvm-callback-support\":[\"dep:jni\"],\"log\":[\"dep:log\"]}}", "diplomat_0.15.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat_core\",\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.7.1\"},{\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"^0.2.30\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.27\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2.0\"}],\"features\":{}}", "diplomat_core_0.15.0": "{\"dependencies\":[{\"name\":\"displaydoc\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"either\",\"optional\":true,\"req\":\"^1.9.0\"},{\"features\":[\"yaml\"],\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.7.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.27\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"smallvec\",\"req\":\"^1.9.0\"},{\"features\":[\"ident\"],\"name\":\"strck\",\"req\":\"^1.0\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"hir\":[\"either\"]}}", @@ -899,7 +906,7 @@ "erased-serde_0.3.31": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_cbor\",\"req\":\"^0.11.2\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.99\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.83\"}],\"features\":{\"alloc\":[\"serde/alloc\"],\"default\":[\"std\"],\"std\":[\"serde/std\"],\"unstable-debug\":[]}}", "errno_0.3.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(target_os=\\\"hermit\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(target_os=\\\"wasi\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Diagnostics_Debug\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"libc/std\"]}}", "error-code_3.3.2": "{\"dependencies\":[],\"features\":{\"std\":[]}}", - "etcetera_0.8.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"home\",\"req\":\"^0.5\"},{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\"^0.48\",\"target\":\"cfg(windows)\"}],\"features\":{}}", + "etcetera_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Com\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "event-listener-strategy_0.5.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"event-listener\",\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.12\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"loom\":[\"event-listener/loom\"],\"portable-atomic\":[\"event-listener/portable-atomic\"],\"std\":[\"event-listener/std\"]}}", "event-listener_5.4.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"concurrent-queue\",\"req\":\"^2.4.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.2.0\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"critical-section\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"name\":\"parking\",\"optional\":true,\"req\":\"^2.0.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.12\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"portable-atomic-util\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"portable_atomic_crate\",\"optional\":true,\"package\":\"portable-atomic\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"try-lock\",\"req\":\"^0.2.5\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"loom\":[\"concurrent-queue/loom\",\"parking?/loom\",\"dep:loom\"],\"portable-atomic\":[\"portable-atomic-util\",\"portable_atomic_crate\",\"concurrent-queue/portable-atomic\"],\"std\":[\"concurrent-queue/std\",\"parking\"]}}", "eventsource-stream_0.2.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"http\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"nom\",\"req\":\"^7.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.8\"},{\"features\":[\"stream\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.11\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.2\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"futures-core/std\",\"nom/std\"]}}", @@ -926,7 +933,6 @@ "fluent-langneg_0.13.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"unic-langid\",\"req\":\"^0.9\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"unic-locale\",\"req\":\"^0.9\"}],\"features\":{\"cldr\":[\"unic-langid/likelysubtags\"],\"default\":[]}}", "fluent-syntax_0.11.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0\"}],\"features\":{\"all-benchmarks\":[],\"default\":[],\"json\":[\"serde\",\"serde_json\"]}}", "fluent_0.16.1": "{\"dependencies\":[{\"name\":\"fluent-bundle\",\"req\":\"^0.15.3\"},{\"name\":\"fluent-pseudo\",\"optional\":true,\"req\":\"^0.3.2\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"}],\"features\":{}}", - "flume_0.11.1": "{\"dependencies\":[{\"features\":[\"attributes\",\"unstable\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"crossbeam-channel\",\"req\":\"^0.5.5\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8.10\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.15\"},{\"features\":[\"getrandom\"],\"name\":\"nanorand\",\"optional\":true,\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"features\":[\"mutex\"],\"name\":\"spin1\",\"package\":\"spin\",\"req\":\"^0.9.8\"},{\"features\":[\"rt\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.16.1\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1.1.0\"}],\"features\":{\"async\":[\"futures-sink\",\"futures-core\"],\"default\":[\"async\",\"select\",\"eventual-fairness\"],\"eventual-fairness\":[\"select\",\"nanorand\"],\"select\":[],\"spin\":[]}}", "flume_0.12.0": "{\"dependencies\":[{\"features\":[\"attributes\",\"unstable\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"crossbeam-channel\",\"req\":\"^0.5.5\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8.10\"},{\"features\":[\"std\",\"js\"],\"name\":\"fastrand\",\"optional\":true,\"req\":\"^2.3\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.15\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"features\":[\"mutex\"],\"name\":\"spin1\",\"package\":\"spin\",\"req\":\"^0.9.8\"},{\"features\":[\"rt\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.16.1\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1.1.0\"}],\"features\":{\"async\":[\"futures-sink\",\"futures-core\"],\"default\":[\"async\",\"select\",\"eventual-fairness\"],\"eventual-fairness\":[\"select\",\"fastrand\"],\"select\":[],\"spin\":[]}}", "fnv_1.0.7": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "foldhash_0.1.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"hashbrown\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.8\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", @@ -938,20 +944,15 @@ "fs_extra_1.3.0": "{\"dependencies\":[],\"features\":{}}", "fsevent-sys_4.1.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.68\"}],\"features\":{}}", "fslock_0.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.66\",\"target\":\"cfg(unix)\"},{\"features\":[\"minwindef\",\"minwinbase\",\"winbase\",\"errhandlingapi\",\"winerror\",\"winnt\",\"synchapi\",\"handleapi\",\"fileapi\",\"processthreadsapi\"],\"name\":\"winapi\",\"req\":\"^0.3.8\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "futures-channel_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.31\"}],\"features\":{\"alloc\":[\"futures-core/alloc\"],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\"],\"unstable\":[]}}", "futures-channel_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.32\"}],\"features\":{\"alloc\":[\"futures-core/alloc\"],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\"],\"unstable\":[]}}", - "futures-core_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.3\"}],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", "futures-core_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.3\"}],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", - "futures-executor_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"name\":\"num_cpus\",\"optional\":true,\"req\":\"^1.8.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"futures-core/std\",\"futures-task/std\",\"futures-util/std\"],\"thread-pool\":[\"std\",\"num_cpus\"]}}", + "futures-executor_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.32\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"futures-core/std\",\"futures-task/std\",\"futures-util/std\"],\"thread-pool\":[\"std\"]}}", "futures-intrusive_0.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"crossbeam\",\"req\":\"^0.7\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4.0\"},{\"name\":\"lock_api\",\"req\":\"^0.4.1\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"kind\":\"dev\",\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"^0.1.11\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.14\"}],\"features\":{\"alloc\":[\"futures-core/alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\",\"parking_lot\"]}}", - "futures-io_0.3.31": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[],\"unstable\":[]}}", + "futures-io_0.3.32": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[],\"unstable\":[]}}", "futures-lite_2.6.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"fastrand\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.5\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.5\"},{\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.3.3\"},{\"name\":\"parking\",\"optional\":true,\"req\":\"^2.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"spin_on\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1.0.0\"}],\"features\":{\"alloc\":[],\"default\":[\"race\",\"std\"],\"race\":[\"fastrand\"],\"std\":[\"alloc\",\"fastrand/std\",\"futures-io\",\"parking\"]}}", - "futures-macro_0.3.31": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{}}", - "futures-sink_0.3.31": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", + "futures-macro_0.3.32": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{}}", "futures-sink_0.3.32": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "futures-task_0.3.31": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", "futures-task_0.3.32": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", - "futures-util_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-macro\",\"optional\":true,\"req\":\"=0.3.31\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.31\"},{\"name\":\"futures_01\",\"optional\":true,\"package\":\"futures\",\"req\":\"^0.1.25\"},{\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.2\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.6\"},{\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^0.1.11\"},{\"name\":\"tokio-io\",\"optional\":true,\"req\":\"^0.1.9\"}],\"features\":{\"alloc\":[\"futures-core/alloc\",\"futures-task/alloc\"],\"async-await\":[],\"async-await-macro\":[\"async-await\",\"futures-macro\"],\"bilock\":[],\"cfg-target-has-atomic\":[],\"channel\":[\"std\",\"futures-channel\"],\"compat\":[\"std\",\"futures_01\"],\"default\":[\"std\",\"async-await\",\"async-await-macro\"],\"io\":[\"std\",\"futures-io\",\"memchr\"],\"io-compat\":[\"io\",\"compat\",\"tokio-io\"],\"portable-atomic\":[\"futures-core/portable-atomic\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\",\"futures-task/std\",\"slab\"],\"unstable\":[\"futures-core/unstable\",\"futures-task/unstable\"],\"write-all-vectored\":[\"io\"]}}", "futures-util_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-macro\",\"optional\":true,\"req\":\"=0.3.32\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.32\"},{\"name\":\"futures_01\",\"optional\":true,\"package\":\"futures\",\"req\":\"^0.1.25\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.26\"},{\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.2\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.6\"},{\"default_features\":false,\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.7\"},{\"name\":\"spin\",\"optional\":true,\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^0.1.11\"},{\"name\":\"tokio-io\",\"optional\":true,\"req\":\"^0.1.9\"}],\"features\":{\"alloc\":[\"futures-core/alloc\",\"futures-task/alloc\",\"slab\"],\"async-await\":[],\"async-await-macro\":[\"async-await\",\"futures-macro\"],\"bilock\":[],\"cfg-target-has-atomic\":[],\"channel\":[\"std\",\"futures-channel\"],\"compat\":[\"std\",\"futures_01\",\"libc\"],\"default\":[\"std\",\"async-await\",\"async-await-macro\"],\"io\":[\"std\",\"futures-io\",\"memchr\"],\"io-compat\":[\"io\",\"compat\",\"tokio-io\",\"libc\"],\"portable-atomic\":[\"futures-core/portable-atomic\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\",\"futures-task/std\",\"slab/std\"],\"unstable\":[\"futures-core/unstable\",\"futures-task/unstable\"],\"write-all-vectored\":[\"io\"]}}", "futures_0.3.31": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.3.0\"},{\"default_features\":false,\"features\":[\"sink\"],\"name\":\"futures-channel\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-executor\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-io\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-sink\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"features\":[\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"kind\":\"dev\",\"name\":\"pin-project\",\"req\":\"^1.0.11\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^0.1.11\"}],\"features\":{\"alloc\":[\"futures-core/alloc\",\"futures-task/alloc\",\"futures-sink/alloc\",\"futures-channel/alloc\",\"futures-util/alloc\"],\"async-await\":[\"futures-util/async-await\",\"futures-util/async-await-macro\"],\"bilock\":[\"futures-util/bilock\"],\"cfg-target-has-atomic\":[],\"compat\":[\"std\",\"futures-util/compat\"],\"default\":[\"std\",\"async-await\",\"executor\"],\"executor\":[\"std\",\"futures-executor/std\"],\"io-compat\":[\"compat\",\"futures-util/io-compat\"],\"std\":[\"alloc\",\"futures-core/std\",\"futures-task/std\",\"futures-io/std\",\"futures-sink/std\",\"futures-util/std\",\"futures-util/io\",\"futures-util/channel\"],\"thread-pool\":[\"executor\",\"futures-executor/thread-pool\"],\"unstable\":[\"futures-core/unstable\",\"futures-task/unstable\",\"futures-channel/unstable\",\"futures-io/unstable\",\"futures-util/unstable\"],\"write-all-vectored\":[\"futures-util/write-all-vectored\"]}}", "fxhash_0.2.1": "{\"dependencies\":[{\"name\":\"byteorder\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"kind\":\"dev\",\"name\":\"seahash\",\"req\":\"^3.0.5\"}],\"features\":{}}", @@ -1048,7 +1049,7 @@ "hashbrown_0.14.5": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.8.7\"},{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7.42\"},{\"features\":[\"validation\"],\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.7.42\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"ahash\",\"inline-more\",\"allocator-api2\"],\"inline-more\":[],\"nightly\":[\"allocator-api2?/nightly\",\"bumpalo/allocator_api\"],\"raw\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"compiler_builtins\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[]}}", "hashbrown_0.15.5": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.1.2\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"default-hasher\",\"inline-more\",\"allocator-api2\",\"equivalent\",\"raw-entry\"],\"default-hasher\":[\"dep:foldhash\"],\"inline-more\":[],\"nightly\":[\"bumpalo/allocator_api\"],\"raw-entry\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[]}}", "hashbrown_0.16.1": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.9.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"default-hasher\",\"inline-more\",\"allocator-api2\",\"equivalent\",\"raw-entry\"],\"default-hasher\":[\"dep:foldhash\"],\"inline-more\":[],\"nightly\":[\"foldhash?/nightly\",\"bumpalo/allocator_api\"],\"raw-entry\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[],\"serde\":[\"dep:serde_core\",\"dep:serde\"]}}", - "hashlink_0.10.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"serde_impl\":[\"serde\"]}}", + "hashlink_0.11.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"default-hasher\"],\"name\":\"hashbrown\",\"req\":\"^0.16\"},{\"kind\":\"dev\",\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"serde_impl\":[\"serde\"]}}", "headers-core_0.3.0": "{\"dependencies\":[{\"name\":\"http\",\"req\":\"^1.0.0\"}],\"features\":{}}", "headers_0.4.1": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"headers-core\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"httpdate\",\"req\":\"^1\"},{\"name\":\"mime\",\"req\":\"^0.3.14\"},{\"name\":\"sha1\",\"req\":\"^0.10\"}],\"features\":{\"nightly\":[]}}", "heapless_0.8.0": "{\"dependencies\":[{\"name\":\"defmt\",\"optional\":true,\"req\":\">=0.2.0, <0.4\"},{\"name\":\"hash32\",\"req\":\"^0.3.0\"},{\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"ufmt\",\"req\":\"^0.2\"},{\"name\":\"ufmt-write\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{\"defmt-03\":[\"dep:defmt\"],\"mpmc_large\":[],\"portable-atomic\":[\"dep:portable-atomic\"],\"portable-atomic-critical-section\":[\"dep:portable-atomic\",\"portable-atomic\",\"portable-atomic?/critical-section\"],\"portable-atomic-unsafe-assume-single-core\":[\"dep:portable-atomic\",\"portable-atomic\",\"portable-atomic?/unsafe-assume-single-core\"],\"serde\":[\"dep:serde\"],\"ufmt\":[\"dep:ufmt-write\"]}}", @@ -1059,7 +1060,9 @@ "hickory-proto_0.25.2": "{\"dependencies\":[{\"name\":\"async-trait\",\"req\":\"^0.1.43\"},{\"default_features\":false,\"features\":[\"prebuilt-nasm\"],\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.12.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.50\"},{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2.4.1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.1.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"data-encoding\",\"req\":\"^2.2.0\"},{\"name\":\"enum-as-inner\",\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-channel\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures-executor\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"req\":\"^0.3.5\"},{\"features\":[\"stream\"],\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.7\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.9\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.1\"},{\"default_features\":false,\"features\":[\"alloc\",\"compiled_data\"],\"name\":\"idna\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"name\":\"ipnet\",\"req\":\"^2.3.0\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.44\"},{\"default_features\":false,\"features\":[\"critical-section\"],\"name\":\"once_cell\",\"req\":\"^1.20.0\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"log\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.2\"},{\"default_features\":false,\"features\":[\"alloc\",\"std_rng\"],\"name\":\"rand\",\"req\":\"^0.9\"},{\"features\":[\"std\"],\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"default_features\":false,\"features\":[\"logging\",\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.23\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.10\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"name\":\"time\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"alloc\"],\"name\":\"tinyvec\",\"req\":\"^1.1.1\"},{\"features\":[\"io-util\",\"macros\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.21\"},{\"features\":[\"rt\",\"time\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21\"},{\"default_features\":false,\"features\":[\"early-data\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\"},{\"default_features\":false,\"name\":\"tracing\",\"req\":\"^0.1.30\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"url\",\"req\":\"^2.5.4\"},{\"name\":\"wasm-bindgen-crate\",\"optional\":true,\"package\":\"wasm-bindgen\",\"req\":\"^0.2.58\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"__dnssec\":[\"dep:bitflags\",\"dep:rustls-pki-types\",\"dep:time\",\"std\"],\"__h3\":[\"dep:h3\",\"dep:h3-quinn\",\"dep:http\",\"std\"],\"__https\":[\"dep:bytes\",\"dep:h2\",\"dep:http\",\"std\"],\"__quic\":[\"dep:bytes\",\"dep:pin-project-lite\",\"dep:quinn\",\"std\"],\"__tls\":[\"dep:bytes\",\"dep:rustls\",\"dep:tokio-rustls\",\"std\",\"tokio\"],\"backtrace\":[\"dep:backtrace\",\"std\"],\"default\":[\"std\",\"tokio\"],\"dnssec-aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/ring-io\",\"__dnssec\"],\"dnssec-ring\":[\"dep:ring\",\"__dnssec\"],\"h3-aws-lc-rs\":[\"quic-aws-lc-rs\",\"__h3\"],\"h3-ring\":[\"quic-ring\",\"__h3\"],\"https-aws-lc-rs\":[\"tls-aws-lc-rs\",\"__https\"],\"https-ring\":[\"tls-ring\",\"__https\"],\"mdns\":[\"socket2/all\",\"std\"],\"no-std-rand\":[\"once_cell/critical-section\",\"dep:critical-section\"],\"quic-aws-lc-rs\":[\"quinn/rustls-aws-lc-rs\",\"tls-aws-lc-rs\",\"__quic\"],\"quic-ring\":[\"quinn/rustls-ring\",\"tls-ring\",\"__quic\"],\"rustls-platform-verifier\":[\"dep:rustls-platform-verifier\",\"std\"],\"serde\":[\"dep:serde\",\"std\",\"url/serde\"],\"std\":[\"data-encoding/std\",\"futures-channel/std\",\"futures-io/std\",\"futures-util/std\",\"ipnet/std\",\"rand/std\",\"rand/thread_rng\",\"ring?/std\",\"thiserror/std\",\"tracing-subscriber/env-filter\",\"tracing-subscriber/fmt\",\"tracing-subscriber/std\",\"tracing/std\",\"url/std\"],\"testing\":[\"std\"],\"text-parsing\":[\"std\"],\"tls-aws-lc-rs\":[\"tokio-rustls/aws-lc-rs\",\"__tls\"],\"tls-ring\":[\"tokio-rustls/ring\",\"__tls\"],\"tokio\":[\"dep:tokio\",\"std\",\"tokio/net\",\"tokio/rt\",\"tokio/time\",\"tokio/rt-multi-thread\"],\"wasm-bindgen\":[\"dep:wasm-bindgen-crate\",\"dep:js-sys\"]}}", "hickory-resolver_0.25.2": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.50\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures-executor\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"hickory-proto\",\"req\":\"^0.25\"},{\"name\":\"ipconfig\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(windows)\"},{\"features\":[\"sync\"],\"name\":\"moka\",\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"critical-section\"],\"name\":\"once_cell\",\"req\":\"^1.20.0\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"log\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"rand\",\"req\":\"^0.9\"},{\"features\":[\"system\"],\"name\":\"resolv-conf\",\"optional\":true,\"req\":\"^0.7.0\"},{\"default_features\":false,\"features\":[\"logging\",\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.23\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smallvec\",\"req\":\"^1.6\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.21\"},{\"features\":[\"macros\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.14\"},{\"default_features\":false,\"name\":\"tracing\",\"req\":\"^0.1.30\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"std\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"__dnssec\":[],\"__h3\":[\"__quic\"],\"__https\":[\"__tls\"],\"__quic\":[\"dep:quinn\",\"__tls\"],\"__tls\":[\"dep:rustls\",\"dep:tokio-rustls\",\"tokio\"],\"backtrace\":[\"dep:backtrace\",\"hickory-proto/backtrace\"],\"default\":[\"system-config\",\"tokio\"],\"dnssec-aws-lc-rs\":[\"hickory-proto/dnssec-aws-lc-rs\",\"__dnssec\"],\"dnssec-ring\":[\"hickory-proto/dnssec-ring\",\"__dnssec\"],\"h3-aws-lc-rs\":[\"hickory-proto/h3-aws-lc-rs\",\"__h3\"],\"h3-ring\":[\"hickory-proto/h3-ring\",\"__h3\"],\"https-aws-lc-rs\":[\"hickory-proto/https-aws-lc-rs\",\"__https\"],\"https-ring\":[\"hickory-proto/https-ring\",\"__https\"],\"quic-aws-lc-rs\":[\"hickory-proto/quic-aws-lc-rs\",\"__quic\",\"quinn/rustls-aws-lc-rs\"],\"quic-ring\":[\"hickory-proto/quic-ring\",\"__quic\",\"quinn/rustls-ring\"],\"rustls-platform-verifier\":[\"hickory-proto/rustls-platform-verifier\"],\"serde\":[\"dep:serde\",\"hickory-proto/serde\"],\"system-config\":[\"dep:ipconfig\",\"dep:resolv-conf\"],\"tls-aws-lc-rs\":[\"hickory-proto/tls-aws-lc-rs\",\"__tls\"],\"tls-ring\":[\"hickory-proto/tls-ring\",\"__tls\"],\"tokio\":[\"dep:tokio\",\"tokio/rt\",\"hickory-proto/tokio\"],\"webpki-roots\":[\"dep:webpki-roots\",\"hickory-proto/webpki-roots\"]}}", "hkdf_0.12.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"blobby\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"hmac\",\"req\":\"^0.12.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"}],\"features\":{\"std\":[\"hmac/std\"]}}", + "hkdf_0.13.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"blobby\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"hmac\",\"req\":\"^0.13\"},{\"name\":\"kdf\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11\"}],\"features\":{}}", "hmac_0.12.1": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.10.3\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"md-5\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha-1\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"streebog\",\"req\":\"^0.10\"}],\"features\":{\"reset\":[],\"std\":[\"digest/std\"]}}", + "hmac_0.13.0": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.11.2\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"md-5\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"streebog\",\"req\":\"^0.11\"}],\"features\":{\"zeroize\":[\"digest/zeroize\"]}}", "home_0.5.12": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "home_0.5.9": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "hostname_0.4.2": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(any(unix, target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.6.1\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"windows-bindgen\",\"req\":\"^0.65\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"default\":[],\"set\":[]}}", @@ -1071,6 +1074,7 @@ "http_1.4.0": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "httparse_1.10.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.5\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "httpdate_1.0.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"}],\"features\":{}}", + "hybrid-array_0.4.12": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"ctutils\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"const-generics\"],\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"},{\"features\":[\"const-generics\"],\"name\":\"typenum\",\"req\":\"^1.20\"},{\"features\":[\"derive\"],\"name\":\"zerocopy\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"}],\"features\":{\"alloc\":[],\"extra-sizes\":[]}}", "hyper-rustls_0.27.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"http\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"hyper\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"client-legacy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"server-auto\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.4\"},{\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rustls\",\"req\":\"^0.23\"},{\"default_features\":false,\"features\":[\"tls12\"],\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^2\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"io-std\",\"macros\",\"net\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"req\":\"^0.26\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"aws-lc-rs\":[\"rustls/aws_lc_rs\"],\"default\":[\"native-tokio\",\"http1\",\"tls12\",\"logging\",\"aws-lc-rs\"],\"fips\":[\"aws-lc-rs\",\"rustls/fips\"],\"http1\":[\"hyper-util/http1\"],\"http2\":[\"hyper-util/http2\"],\"logging\":[\"log\",\"tokio-rustls/logging\",\"rustls/logging\"],\"native-tokio\":[\"rustls-native-certs\"],\"ring\":[\"rustls/ring\"],\"tls12\":[\"tokio-rustls/tls12\",\"rustls/tls12\"],\"webpki-tokio\":[\"webpki-roots\"]}}", "hyper-timeout_0.5.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"name\":\"hyper\",\"req\":\"^1.1\"},{\"features\":[\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"hyper-tls\",\"req\":\"^0.6\"},{\"features\":[\"client-legacy\",\"http1\"],\"name\":\"hyper-util\",\"req\":\"^0.1.10\"},{\"features\":[\"client-legacy\",\"http1\",\"server\",\"server-graceful\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.10\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"name\":\"tokio\",\"req\":\"^1.35\"},{\"features\":[\"io-std\",\"io-util\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.35\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"}],\"features\":{}}", "hyper-tls_0.6.0": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"client-legacy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.0\"},{\"features\":[\"http1\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.0\"},{\"name\":\"native-tls\",\"req\":\"^0.2.1\"},{\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"io-std\",\"macros\",\"io-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"name\":\"tokio-native-tls\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"}],\"features\":{\"alpn\":[\"native-tls/alpn\"],\"vendored\":[\"native-tls/vendored\"]}}", @@ -1149,8 +1153,12 @@ "jiff-tzdb-platform_0.1.3": "{\"dependencies\":[{\"name\":\"jiff-tzdb\",\"req\":\"^0.1.4\"}],\"features\":{}}", "jiff-tzdb_0.1.6": "{\"dependencies\":[],\"features\":{}}", "jiff_0.2.23": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.81\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"hifitime\",\"req\":\"^3.9.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"humantime\",\"req\":\"^2.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.39.0\"},{\"name\":\"jiff-static\",\"req\":\"=0.2.23\",\"target\":\"cfg(any())\"},{\"name\":\"jiff-static\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.6\"},{\"name\":\"jiff-tzdb-platform\",\"optional\":true,\"req\":\"^0.1.3\",\"target\":\"cfg(any(windows, target_family = \\\"wasm\\\"))\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.50\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.21\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"req\":\"^1.10.0\",\"target\":\"cfg(not(target_has_atomic = \\\"ptr\\\"))\"},{\"default_features\":false,\"name\":\"portable-atomic-util\",\"req\":\"^0.2.4\",\"target\":\"cfg(not(target_has_atomic = \\\"ptr\\\"))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.203\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.117\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.9.34\"},{\"kind\":\"dev\",\"name\":\"tabwriter\",\"req\":\"^1.4.0\"},{\"features\":[\"local-offset\",\"macros\",\"parsing\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.36\"},{\"kind\":\"dev\",\"name\":\"time-tz\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"tzfile\",\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.70\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"features\":[\"Win32_Foundation\",\"Win32_System_Time\"],\"name\":\"windows-sys\",\"optional\":true,\"req\":\">=0.52.0, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"portable-atomic-util/alloc\"],\"default\":[\"std\",\"tz-system\",\"tz-fat\",\"tzdb-bundle-platform\",\"tzdb-zoneinfo\",\"tzdb-concatenated\",\"perf-inline\"],\"js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"],\"logging\":[\"dep:log\"],\"perf-inline\":[],\"serde\":[\"dep:serde_core\"],\"static\":[\"static-tz\",\"jiff-static?/tzdb\"],\"static-tz\":[\"dep:jiff-static\"],\"std\":[\"alloc\",\"log?/std\",\"serde_core?/std\"],\"tz-fat\":[\"jiff-static?/tz-fat\"],\"tz-system\":[\"std\",\"dep:windows-sys\"],\"tzdb-bundle-always\":[\"dep:jiff-tzdb\",\"alloc\"],\"tzdb-bundle-platform\":[\"dep:jiff-tzdb-platform\",\"alloc\"],\"tzdb-concatenated\":[\"std\"],\"tzdb-zoneinfo\":[\"std\"]}}", + "jni-macros_0.22.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"javac\",\"req\":\"^0.1.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rusty-fork\",\"req\":\"^0.3.0\"},{\"name\":\"simd_cesu8\",\"req\":\"^1.0.1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{}}", + "jni-sys-macros_0.4.1": "{\"dependencies\":[{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "jni-sys_0.3.0": "{\"dependencies\":[],\"features\":{}}", + "jni-sys_0.4.1": "{\"dependencies\":[{\"name\":\"jni-sys-macros\",\"req\":\"^0.4.1\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{}}", "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\"]}}", + "jni_0.22.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.13.0\",\"target\":\"cfg(windows)\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"combine\",\"req\":\"^4.1.0\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"name\":\"java-locator\",\"optional\":true,\"req\":\"^0.1.3\",\"target\":\"cfg(not(target_os = \\\"android\\\"))\"},{\"kind\":\"dev\",\"name\":\"javac\",\"req\":\"^0.1.0\"},{\"name\":\"jni-macros\",\"req\":\"=0.22.4\"},{\"name\":\"jni-sys\",\"req\":\"^0.4.1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"libloading\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(not(target_os = \\\"android\\\"))\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"rusty-fork\",\"req\":\"^0.3.0\"},{\"name\":\"simd_cesu8\",\"req\":\"^1.1.1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"walkdir\",\"req\":\"^2\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_System_Threading\",\"Win32_Foundation\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"_cfg_test\":[],\"default\":[],\"invocation\":[\"dep:java-locator\",\"dep: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\"]}}", @@ -1173,7 +1181,7 @@ "libm_0.2.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"no-panic\",\"req\":\"^0.1.35\"}],\"features\":{\"arch\":[],\"default\":[\"arch\"],\"force-soft-floats\":[],\"unstable\":[\"unstable-intrinsics\",\"unstable-float\"],\"unstable-float\":[],\"unstable-intrinsics\":[],\"unstable-public-internals\":[]}}", "libredox_0.1.12": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"call\":[],\"default\":[\"call\",\"std\",\"redox_syscall\"],\"mkns\":[\"ioslice\"],\"std\":[]}}", "libredox_0.1.14": "{\"dependencies\":[{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"plain\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"base\":[\"libc\"],\"call\":[\"base\"],\"default\":[\"base\",\"call\",\"std\",\"redox_syscall\",\"protocol\"],\"mkns\":[\"ioslice\"],\"protocol\":[\"plain\",\"bitflags\",\"redox_syscall\"],\"std\":[\"base\"]}}", - "libsqlite3-sys_0.30.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.69\"},{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.1.6\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.103\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3.19\"},{\"kind\":\"build\",\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.20\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.36\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"kind\":\"build\",\"name\":\"syn\",\"optional\":true,\"req\":\"^2.0.72\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"optional\":true,\"req\":\"^0.2.15\"}],\"features\":{\"buildtime_bindgen\":[\"bindgen\",\"pkg-config\",\"vcpkg\"],\"bundled\":[\"cc\",\"bundled_bindings\"],\"bundled-sqlcipher\":[\"bundled\"],\"bundled-sqlcipher-vendored-openssl\":[\"bundled-sqlcipher\",\"openssl-sys/vendored\"],\"bundled-windows\":[\"cc\",\"bundled_bindings\"],\"bundled_bindings\":[],\"default\":[\"min_sqlite_version_3_14_0\"],\"in_gecko\":[],\"loadable_extension\":[\"prettyplease\",\"quote\",\"syn\"],\"min_sqlite_version_3_14_0\":[\"pkg-config\",\"vcpkg\"],\"preupdate_hook\":[\"buildtime_bindgen\"],\"session\":[\"preupdate_hook\",\"buildtime_bindgen\"],\"sqlcipher\":[],\"unlock_notify\":[],\"wasm32-wasi-vfs\":[],\"with-asan\":[]}}", + "libsqlite3-sys_0.37.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72\"},{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.2.27\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.103\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3.19\"},{\"kind\":\"build\",\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.20\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.36\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"kind\":\"build\",\"name\":\"syn\",\"optional\":true,\"req\":\"^2.0.89\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"optional\":true,\"req\":\"^0.2.15\"}],\"features\":{\"buildtime_bindgen\":[\"bindgen\",\"pkg-config\",\"vcpkg\"],\"bundled\":[\"cc\",\"bundled_bindings\"],\"bundled-sqlcipher\":[\"bundled\"],\"bundled-sqlcipher-vendored-openssl\":[\"bundled-sqlcipher\",\"openssl-sys/vendored\"],\"bundled-windows\":[\"cc\",\"bundled_bindings\"],\"bundled_bindings\":[],\"column_metadata\":[],\"default\":[\"min_sqlite_version_3_34_1\"],\"in_gecko\":[],\"loadable_extension\":[\"prettyplease\",\"quote\",\"syn\"],\"min_sqlite_version_3_34_1\":[\"pkg-config\",\"vcpkg\"],\"preupdate_hook\":[\"buildtime_bindgen\"],\"session\":[\"preupdate_hook\",\"buildtime_bindgen\"],\"sqlcipher\":[],\"unlock_notify\":[],\"wasm32-wasi-vfs\":[],\"with-asan\":[]}}", "libssh2-sys_0.3.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.25\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"libc\"],\"name\":\"libz-sys\",\"req\":\"^1.1.0\"},{\"name\":\"openssl-sys\",\"req\":\"^0.9.35\",\"target\":\"cfg(unix)\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.35\",\"target\":\"cfg(windows)\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.11\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2\",\"target\":\"cfg(target_env = \\\"msvc\\\")\"}],\"features\":{\"openssl-on-win32\":[\"openssl-sys\"],\"vendored-openssl\":[\"openssl-sys/vendored\"],\"zlib-ng-compat\":[\"libz-sys/zlib-ng\"]}}", "libz-sys_1.1.23": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", "libz-sys_1.1.25": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", @@ -1203,7 +1211,7 @@ "matchit_0.8.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-router\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.4\"},{\"kind\":\"dev\",\"name\":\"gonzales\",\"req\":\"^0.0.3-beta\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"path-tree\",\"req\":\"^0.2.2\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.5.4\"},{\"kind\":\"dev\",\"name\":\"route-recognizer\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"routefinder\",\"req\":\"^0.5.2\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"make\",\"util\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.4\"}],\"features\":{\"__test_helpers\":[],\"default\":[]}}", "matchit_0.9.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-router\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"gonzales\",\"req\":\"^0.0.3-beta\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"http1\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"path-tree\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"route-recognizer\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"routefinder\",\"req\":\"^0.5\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"make\",\"util\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"kind\":\"dev\",\"name\":\"wayfind\",\"req\":\"^0.8\"}],\"features\":{\"__test_helpers\":[],\"default\":[]}}", "maybe-async_0.2.10": "{\"dependencies\":[{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"visit-mut\",\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{\"default\":[],\"is_sync\":[]}}", - "md-5_0.10.6": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"md5-asm\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(any(target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"}],\"features\":{\"asm\":[\"md5-asm\"],\"default\":[\"std\"],\"force-soft\":[],\"loongarch64_asm\":[],\"oid\":[\"digest/oid\"],\"std\":[\"digest/std\"]}}", + "md-5_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"digest\",\"req\":\"^0.11\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"digest/alloc\"],\"default\":[\"alloc\",\"oid\"],\"oid\":[\"digest/oid\"],\"zeroize\":[\"digest/zeroize\"]}}", "md5_0.8.0": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "memchr_2.7.6": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"libc\":[],\"logging\":[\"dep:log\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[\"alloc\"],\"use_std\":[\"std\"]}}", "memchr_2.8.0": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"libc\":[],\"logging\":[\"dep:log\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[\"alloc\"],\"use_std\":[\"std\"]}}", @@ -1235,7 +1243,6 @@ "notify-types_2.1.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.34.0\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.89\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.39\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1.0\"}],\"features\":{\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"serialization-compat-6\":[]}}", "notify_8.2.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.7.0\",\"target\":\"cfg(target_os=\\\"macos\\\")\"},{\"name\":\"crossbeam-channel\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"flume\",\"optional\":true,\"req\":\"^0.11.1\"},{\"name\":\"fsevent-sys\",\"optional\":true,\"req\":\"^4.0.0\",\"target\":\"cfg(target_os=\\\"macos\\\")\"},{\"default_features\":false,\"name\":\"inotify\",\"req\":\"^0.11.0\",\"target\":\"cfg(any(target_os=\\\"linux\\\", target_os=\\\"android\\\"))\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.34.0\"},{\"name\":\"kqueue\",\"req\":\"^1.1.1\",\"target\":\"cfg(any(target_os=\\\"freebsd\\\", target_os=\\\"openbsd\\\", target_os = \\\"netbsd\\\", target_os = \\\"dragonflybsd\\\", target_os = \\\"ios\\\"))\"},{\"name\":\"kqueue\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(target_os=\\\"macos\\\")\"},{\"name\":\"libc\",\"req\":\"^0.2.4\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"features\":[\"os-ext\"],\"name\":\"mio\",\"req\":\"^1.0\",\"target\":\"cfg(any(target_os=\\\"freebsd\\\", target_os=\\\"openbsd\\\", target_os = \\\"netbsd\\\", target_os = \\\"dragonflybsd\\\", target_os = \\\"ios\\\"))\"},{\"features\":[\"os-ext\"],\"name\":\"mio\",\"req\":\"^1.0\",\"target\":\"cfg(any(target_os=\\\"linux\\\", target_os=\\\"android\\\"))\"},{\"features\":[\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0\",\"target\":\"cfg(target_os=\\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.29.0\"},{\"name\":\"notify-types\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.39\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.0\"},{\"kind\":\"dev\",\"name\":\"trash\",\"req\":\"^5.2.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"walkdir\",\"req\":\"^2.4.0\"},{\"features\":[\"Win32_System_Threading\",\"Win32_Foundation\",\"Win32_Storage_FileSystem\",\"Win32_Security\",\"Win32_System_WindowsProgramming\",\"Win32_System_IO\"],\"name\":\"windows-sys\",\"req\":\"^0.60.1\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"macos_fsevent\"],\"macos_fsevent\":[\"fsevent-sys\"],\"macos_kqueue\":[\"kqueue\",\"mio\"],\"serde\":[\"notify-types/serde\"],\"serialization-compat-6\":[\"notify-types/serialization-compat-6\"]}}", "nu-ansi-term_0.50.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.3\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.152\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.94\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\",\"Win32_Storage_FileSystem\",\"Win32_Security\"],\"name\":\"windows\",\"package\":\"windows-sys\",\"req\":\">=0.59, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"std\"],\"derive_serde_style\":[\"serde\"],\"gnu_legacy\":[],\"std\":[]}}", - "num-bigint-dig_0.8.6": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"spin_no_std\"],\"name\":\"lazy_static\",\"req\":\"^1.2.0\"},{\"name\":\"libm\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-integer\",\"req\":\"^0.1.39\"},{\"default_features\":false,\"name\":\"num-iter\",\"req\":\"^0.1.37\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-traits\",\"req\":\"^0.2.4\"},{\"default_features\":false,\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8.3\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand_isaac\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"smallvec\",\"req\":\"^1.10.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"default\":[\"std\",\"u64_digit\"],\"fuzz\":[\"arbitrary\",\"smallvec/arbitrary\"],\"i128\":[],\"nightly\":[],\"prime\":[\"rand/std_rng\"],\"std\":[\"num-integer/std\",\"num-traits/std\",\"smallvec/write\",\"rand/std\",\"serde/std\"],\"u64_digit\":[]}}", "num-bigint_0.4.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-integer\",\"req\":\"^0.1.46\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-traits\",\"req\":\"^0.2.18\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\"],\"quickcheck\":[\"dep:quickcheck\"],\"rand\":[\"dep:rand\"],\"serde\":[\"dep:serde\"],\"std\":[\"num-integer/std\",\"num-traits/std\"]}}", "num-complex_0.4.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytecheck\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-traits\",\"req\":\"^0.2.18\"},{\"default_features\":false,\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"bytecheck\":[\"dep:bytecheck\"],\"bytemuck\":[\"dep:bytemuck\"],\"default\":[\"std\"],\"libm\":[\"num-traits/libm\"],\"rand\":[\"dep:rand\"],\"rkyv\":[\"dep:rkyv\"],\"serde\":[\"dep:serde\"],\"std\":[\"num-traits/std\"]}}", "num-conv_0.2.0": "{\"dependencies\":[],\"features\":{}}", @@ -1324,7 +1331,6 @@ "pin-project_1.1.11": "{\"dependencies\":[{\"name\":\"pin-project-internal\",\"req\":\"=1.1.11\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}", "pin-utils_0.1.0": "{\"dependencies\":[],\"features\":{}}", "piper_0.2.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-channel\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"async-executor\",\"req\":\"^1.5.1\"},{\"kind\":\"dev\",\"name\":\"async-io\",\"req\":\"^2.0.0\"},{\"name\":\"atomic-waker\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.2.0\"},{\"default_features\":false,\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.28\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"features\":[\"alloc\"],\"name\":\"portable-atomic-util\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"portable_atomic_crate\",\"optional\":true,\"package\":\"portable-atomic\",\"req\":\"^1.2.0\"}],\"features\":{\"default\":[\"std\"],\"portable-atomic\":[\"atomic-waker/portable-atomic\",\"portable_atomic_crate\",\"portable-atomic-util\"],\"std\":[\"fastrand/std\",\"futures-io\"]}}", - "pkcs1_0.7.5": "{\"dependencies\":[{\"features\":[\"db\"],\"kind\":\"dev\",\"name\":\"const-oid\",\"req\":\"^0.9\"},{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"pkcs8\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"spki\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"alloc\":[\"der/alloc\",\"zeroize\",\"pkcs8?/alloc\"],\"pem\":[\"alloc\",\"der/pem\",\"pkcs8?/pem\"],\"std\":[\"der/std\",\"alloc\"],\"zeroize\":[\"der/zeroize\"]}}", "pkcs8_0.10.2": "{\"dependencies\":[{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"name\":\"pkcs5\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"spki\",\"req\":\"^0.7.1\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"3des\":[\"encryption\",\"pkcs5/3des\"],\"alloc\":[\"der/alloc\",\"der/zeroize\",\"spki/alloc\"],\"des-insecure\":[\"encryption\",\"pkcs5/des-insecure\"],\"encryption\":[\"alloc\",\"pkcs5/alloc\",\"pkcs5/pbes2\",\"rand_core\"],\"getrandom\":[\"rand_core/getrandom\"],\"pem\":[\"alloc\",\"der/pem\",\"spki/pem\"],\"sha1-insecure\":[\"encryption\",\"pkcs5/sha1-insecure\"],\"std\":[\"alloc\",\"der/std\",\"spki/std\"]}}", "pkg-config_0.3.32": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"}],\"features\":{}}", "plain_0.2.3": "{\"dependencies\":[],\"features\":{}}", @@ -1400,10 +1406,12 @@ "rama-udp_0.3.0-alpha.4": "{\"dependencies\":[{\"name\":\"rama-core\",\"req\":\"^0.3.0-alpha.4\"},{\"name\":\"rama-net\",\"req\":\"^0.3.0-alpha.4\"},{\"features\":[\"macros\",\"net\"],\"name\":\"tokio\",\"req\":\"^1.48\"},{\"features\":[\"net\"],\"name\":\"tokio-util\",\"req\":\"^0.7\"}],\"features\":{\"default\":[]}}", "rama-unix_0.3.0-alpha.4": "{\"dependencies\":[{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"name\":\"rama-core\",\"req\":\"^0.3.0-alpha.4\"},{\"name\":\"rama-net\",\"req\":\"^0.3.0-alpha.4\"},{\"features\":[\"macros\",\"net\"],\"name\":\"tokio\",\"req\":\"^1.48\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.48\"}],\"features\":{\"default\":[]}}", "rama-utils_0.3.0-alpha.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"name\":\"const_format\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"name\":\"rama-macros\",\"req\":\"^0.3.0-alpha.4\"},{\"name\":\"regex\",\"req\":\"^1.12\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1\"},{\"features\":[\"write\",\"serde\",\"const_generics\",\"const_new\"],\"name\":\"smallvec\",\"req\":\"^1.15\"},{\"name\":\"smol_str\",\"req\":\"^0.3\"},{\"features\":[\"time\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.48\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"},{\"name\":\"wildcard\",\"req\":\"^0.3\"}],\"features\":{}}", + "rand_0.10.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"rng\"],\"name\":\"chacha20\",\"optional\":true,\"req\":\"^0.10.0\"},{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.1.3\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.140\"}],\"features\":{\"alloc\":[],\"chacha\":[\"dep:chacha20\"],\"default\":[\"std\",\"std_rng\",\"sys_rng\",\"thread_rng\"],\"log\":[],\"serde\":[\"dep:serde\"],\"simd_support\":[],\"std\":[\"alloc\",\"getrandom?/std\"],\"std_rng\":[\"dep:chacha20\"],\"sys_rng\":[\"dep:getrandom\",\"getrandom/sys_rng\"],\"thread_rng\":[\"std\",\"std_rng\",\"sys_rng\"],\"unbiased\":[]}}", "rand_0.8.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.2.1\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.22\",\"target\":\"cfg(unix)\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.4\"},{\"features\":[\"into_bits\"],\"name\":\"packed_simd\",\"optional\":true,\"package\":\"packed_simd_2\",\"req\":\"^0.3.7\"},{\"default_features\":false,\"name\":\"rand_chacha\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"rand_core\",\"req\":\"^0.6.0\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.3.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"}],\"features\":{\"alloc\":[\"rand_core/alloc\"],\"default\":[\"std\",\"std_rng\"],\"getrandom\":[\"rand_core/getrandom\"],\"min_const_gen\":[],\"nightly\":[],\"serde1\":[\"serde\",\"rand_core/serde1\"],\"simd_support\":[\"packed_simd\"],\"small_rng\":[],\"std\":[\"rand_core/std\",\"rand_chacha/std\",\"alloc\",\"getrandom\",\"libc\"],\"std_rng\":[\"rand_chacha\"]}}", "rand_0.9.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.2.1\"},{\"default_features\":false,\"name\":\"rand_chacha\",\"optional\":true,\"req\":\"^0.9.0\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.140\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"std_rng\",\"os_rng\",\"small_rng\",\"thread_rng\"],\"log\":[],\"nightly\":[],\"os_rng\":[\"rand_core/os_rng\"],\"serde\":[\"dep:serde\",\"rand_core/serde\"],\"simd_support\":[],\"small_rng\":[],\"std\":[\"rand_core/std\",\"rand_chacha?/std\",\"alloc\"],\"std_rng\":[\"dep:rand_chacha\"],\"thread_rng\":[\"std\",\"std_rng\",\"os_rng\"],\"unbiased\":[]}}", "rand_chacha_0.3.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"simd\"],\"name\":\"ppv-lite86\",\"req\":\"^0.2.8\"},{\"name\":\"rand_core\",\"req\":\"^0.6.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"serde1\":[\"serde\"],\"simd\":[],\"std\":[\"ppv-lite86/std\"]}}", "rand_chacha_0.9.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"simd\"],\"name\":\"ppv-lite86\",\"req\":\"^0.2.14\"},{\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"features\":[\"os_rng\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"os_rng\":[\"rand_core/os_rng\"],\"serde\":[\"dep:serde\"],\"std\":[\"ppv-lite86/std\",\"rand_core/std\"]}}", + "rand_core_0.10.1": "{\"dependencies\":[],\"features\":{}}", "rand_core_0.6.4": "{\"dependencies\":[{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[],\"serde1\":[\"serde\"],\"std\":[\"alloc\",\"getrandom\",\"getrandom/std\"]}}", "rand_core_0.9.5": "{\"dependencies\":[{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"os_rng\":[\"dep:getrandom\"],\"serde\":[\"dep:serde\"],\"std\":[\"getrandom?/std\"]}}", "rand_xorshift_0.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.118\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", @@ -1426,13 +1434,13 @@ "regex-syntax_0.8.8": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\",\"unicode\"],\"std\":[],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\"],\"unicode-age\":[],\"unicode-bool\":[],\"unicode-case\":[],\"unicode-gencat\":[],\"unicode-perl\":[],\"unicode-script\":[],\"unicode-segment\":[]}}", "regex_1.12.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aho-corasick\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.69\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"atty\",\"humantime\",\"termcolor\"],\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.3\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.6.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"alloc\",\"syntax\",\"meta\",\"nfa-pikevm\"],\"name\":\"regex-automata\",\"req\":\"^0.4.12\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"regex-test\",\"req\":\"^0.1.0\"}],\"features\":{\"default\":[\"std\",\"perf\",\"unicode\",\"regex-syntax/default\"],\"logging\":[\"aho-corasick?/logging\",\"memchr?/logging\",\"regex-automata/logging\"],\"pattern\":[],\"perf\":[\"perf-cache\",\"perf-dfa\",\"perf-onepass\",\"perf-backtrack\",\"perf-inline\",\"perf-literal\"],\"perf-backtrack\":[\"regex-automata/nfa-backtrack\"],\"perf-cache\":[],\"perf-dfa\":[\"regex-automata/hybrid\"],\"perf-dfa-full\":[\"regex-automata/dfa-build\",\"regex-automata/dfa-search\"],\"perf-inline\":[\"regex-automata/perf-inline\"],\"perf-literal\":[\"dep:aho-corasick\",\"dep:memchr\",\"regex-automata/perf-literal\"],\"perf-onepass\":[\"regex-automata/dfa-onepass\"],\"std\":[\"aho-corasick?/std\",\"memchr?/std\",\"regex-automata/std\",\"regex-syntax/std\"],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\",\"regex-automata/unicode\",\"regex-syntax/unicode\"],\"unicode-age\":[\"regex-automata/unicode-age\",\"regex-syntax/unicode-age\"],\"unicode-bool\":[\"regex-automata/unicode-bool\",\"regex-syntax/unicode-bool\"],\"unicode-case\":[\"regex-automata/unicode-case\",\"regex-syntax/unicode-case\"],\"unicode-gencat\":[\"regex-automata/unicode-gencat\",\"regex-syntax/unicode-gencat\"],\"unicode-perl\":[\"regex-automata/unicode-perl\",\"regex-automata/unicode-word-boundary\",\"regex-syntax/unicode-perl\"],\"unicode-script\":[\"regex-automata/unicode-script\",\"regex-syntax/unicode-script\"],\"unicode-segment\":[\"regex-automata/unicode-segment\",\"regex-syntax/unicode-segment\"],\"unstable\":[\"pattern\"],\"use_std\":[\"std\"]}}", "reqwest_0.12.28": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"brotli_crate\",\"package\":\"brotli\",\"req\":\"^8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"cookie_crate\",\"optional\":true,\"package\":\"cookie\",\"req\":\"^0.18.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"cookie_store\",\"optional\":true,\"req\":\"^0.22.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"std\",\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.28\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"tokio\"],\"name\":\"hickory-resolver\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http\",\"req\":\"^1.1\"},{\"name\":\"http-body\",\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\"],\"name\":\"hyper\",\"req\":\"^1.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"http2\",\"client\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"hyper-tls\",\"optional\":true,\"req\":\"^0.6\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\",\"client-legacy\",\"client-proxy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"http2\",\"client\",\"client-legacy\",\"server-auto\",\"server-graceful\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.77\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0\"},{\"name\":\"log\",\"req\":\"^0.4.17\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3.16\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.18\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"rustls\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.9.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde_json\",\"req\":\"^1.0\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"},{\"features\":[\"futures\"],\"name\":\"sync_wrapper\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"net\",\"time\"],\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.9\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"retry\",\"timeout\",\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"features\":[\"follow-redirect\"],\"name\":\"tower-http\",\"req\":\"^0.6.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tower-service\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"url\",\"req\":\"^2.4\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"serde-serialize\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.18\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-streams\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"AbortController\",\"AbortSignal\",\"Headers\",\"Request\",\"RequestInit\",\"RequestMode\",\"Response\",\"Window\",\"FormData\",\"Blob\",\"BlobPropertyBag\",\"ServiceWorkerGlobalScope\",\"RequestCredentials\",\"File\",\"ReadableStream\",\"RequestCache\"],\"name\":\"web-sys\",\"req\":\"^0.3.28\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"zstd_crate\",\"package\":\"zstd\",\"req\":\"^0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"}],\"features\":{\"__rustls\":[\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"dep:rustls\",\"__tls\"],\"__rustls-ring\":[\"hyper-rustls?/ring\",\"tokio-rustls?/ring\",\"rustls?/ring\",\"quinn?/ring\"],\"__tls\":[\"dep:rustls-pki-types\",\"tokio/io-util\"],\"blocking\":[\"dep:futures-channel\",\"futures-channel?/sink\",\"dep:futures-util\",\"futures-util?/io\",\"futures-util?/sink\",\"tokio/sync\"],\"brotli\":[\"tower-http/decompression-br\"],\"charset\":[\"dep:encoding_rs\",\"dep:mime\"],\"cookies\":[\"dep:cookie_crate\",\"dep:cookie_store\"],\"default\":[\"default-tls\",\"charset\",\"http2\",\"system-proxy\"],\"default-tls\":[\"dep:hyper-tls\",\"dep:native-tls-crate\",\"__tls\",\"dep:tokio-native-tls\"],\"deflate\":[\"tower-http/decompression-deflate\"],\"gzip\":[\"tower-http/decompression-gzip\"],\"hickory-dns\":[\"dep:hickory-resolver\",\"dep:once_cell\"],\"http2\":[\"h2\",\"hyper/http2\",\"hyper-util/http2\",\"hyper-rustls?/http2\"],\"http3\":[\"rustls-tls-manual-roots\",\"dep:h3\",\"dep:h3-quinn\",\"dep:quinn\",\"tokio/macros\"],\"json\":[\"dep:serde_json\"],\"macos-system-configuration\":[\"system-proxy\"],\"multipart\":[\"dep:mime_guess\",\"dep:futures-util\"],\"native-tls\":[\"default-tls\"],\"native-tls-alpn\":[\"native-tls\",\"native-tls-crate?/alpn\",\"hyper-tls?/alpn\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate?/vendored\"],\"rustls-tls\":[\"rustls-tls-webpki-roots\"],\"rustls-tls-manual-roots\":[\"rustls-tls-manual-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-manual-roots-no-provider\":[\"__rustls\"],\"rustls-tls-native-roots\":[\"rustls-tls-native-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-native-roots-no-provider\":[\"dep:rustls-native-certs\",\"hyper-rustls?/native-tokio\",\"__rustls\"],\"rustls-tls-no-provider\":[\"rustls-tls-manual-roots-no-provider\"],\"rustls-tls-webpki-roots\":[\"rustls-tls-webpki-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-webpki-roots-no-provider\":[\"dep:webpki-roots\",\"hyper-rustls?/webpki-tokio\",\"__rustls\"],\"socks\":[],\"stream\":[\"tokio/fs\",\"dep:futures-util\",\"dep:tokio-util\",\"dep:wasm-streams\"],\"system-proxy\":[\"hyper-util/client-proxy-system\"],\"trust-dns\":[],\"zstd\":[\"tower-http/decompression-zstd\"]}}", + "reqwest_0.13.4": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"brotli_crate\",\"package\":\"brotli\",\"req\":\"^8\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"cookie_crate\",\"optional\":true,\"package\":\"cookie\",\"req\":\"^0.18.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"cookie_store\",\"optional\":true,\"req\":\"^0.22.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0.13\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"std\",\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.28\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.8\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.10\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"features\":[\"tokio\"],\"name\":\"hickory-resolver\",\"optional\":true,\"req\":\"^0.26\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"http\",\"req\":\"^1.1\"},{\"name\":\"http-body\",\"req\":\"^1\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.2\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"features\":[\"http1\",\"client\"],\"name\":\"hyper\",\"req\":\"^1.1\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"http1\",\"http2\",\"client\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"hyper-tls\",\"optional\":true,\"req\":\"^0.6\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"features\":[\"http1\",\"client\",\"client-legacy\",\"client-proxy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"features\":[\"http1\",\"http2\",\"client\",\"client-legacy\",\"server-auto\",\"server-graceful\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.77\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0\"},{\"name\":\"log\",\"req\":\"^0.4.17\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3.16\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.16\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.18\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.1\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.4\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.9.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\">=0.6.0, <0.8.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_urlencoded\",\"optional\":true,\"req\":\"^0.7.1\"},{\"features\":[\"futures\"],\"name\":\"sync_wrapper\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"net\",\"time\"],\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.9\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"retry\",\"timeout\",\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"features\":[\"follow-redirect\"],\"name\":\"tower-http\",\"req\":\"^0.6.8\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"tower-service\",\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"url\",\"req\":\"^2.4\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"features\":[\"serde-serialize\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.18\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"name\":\"wasm-streams\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"features\":[\"AbortController\",\"AbortSignal\",\"Headers\",\"Request\",\"RequestInit\",\"RequestMode\",\"Response\",\"Window\",\"FormData\",\"Blob\",\"BlobPropertyBag\",\"ServiceWorkerGlobalScope\",\"RequestCredentials\",\"File\",\"ReadableStream\",\"RequestCache\"],\"name\":\"web-sys\",\"req\":\"^0.3.28\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"zstd_crate\",\"package\":\"zstd\",\"req\":\"^0.13\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"}],\"features\":{\"__native-tls\":[\"dep:hyper-tls\",\"dep:native-tls-crate\",\"__tls\",\"dep:tokio-native-tls\"],\"__native-tls-alpn\":[\"native-tls-crate?/alpn\",\"hyper-tls?/alpn\"],\"__rustls\":[\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"dep:rustls\",\"__tls\"],\"__rustls-aws-lc-rs\":[\"hyper-rustls?/aws-lc-rs\",\"tokio-rustls?/aws-lc-rs\",\"rustls?/aws-lc-rs\",\"quinn?/rustls-aws-lc-rs\"],\"__tls\":[\"dep:rustls-pki-types\",\"tokio/io-util\"],\"blocking\":[\"dep:futures-channel\",\"futures-channel?/sink\",\"dep:futures-util\",\"futures-util?/io\",\"futures-util?/sink\",\"tokio/sync\"],\"brotli\":[\"tower-http/decompression-br\"],\"charset\":[\"dep:encoding_rs\",\"dep:mime\"],\"cookies\":[\"dep:cookie_crate\",\"dep:cookie_store\"],\"default\":[\"default-tls\",\"charset\",\"http2\",\"system-proxy\"],\"default-tls\":[\"rustls\"],\"deflate\":[\"tower-http/decompression-deflate\"],\"form\":[\"dep:serde\",\"dep:serde_urlencoded\"],\"gzip\":[\"tower-http/decompression-gzip\"],\"hickory-dns\":[\"dep:hickory-resolver\",\"dep:once_cell\"],\"http2\":[\"dep:h2\",\"hyper/http2\",\"hyper-util/http2\",\"hyper-rustls?/http2\"],\"http3\":[\"rustls\",\"dep:h3\",\"dep:h3-quinn\",\"dep:quinn\",\"tokio/macros\"],\"json\":[\"dep:serde\",\"dep:serde_json\"],\"multipart\":[\"dep:mime_guess\",\"dep:futures-util\"],\"native-tls\":[\"__native-tls\",\"__native-tls-alpn\"],\"native-tls-no-alpn\":[\"__native-tls\"],\"native-tls-vendored\":[\"__native-tls\",\"native-tls-crate?/vendored\",\"__native-tls-alpn\"],\"native-tls-vendored-no-alpn\":[\"__native-tls\",\"native-tls-crate?/vendored\"],\"query\":[\"dep:serde\",\"dep:serde_urlencoded\"],\"rustls\":[\"__rustls-aws-lc-rs\",\"dep:rustls-platform-verifier\",\"__rustls\"],\"rustls-no-provider\":[\"dep:rustls-platform-verifier\",\"__rustls\"],\"socks\":[],\"stream\":[\"tokio/fs\",\"dep:futures-util\",\"dep:tokio-util\",\"dep:wasm-streams\"],\"system-proxy\":[\"hyper-util/client-proxy-system\"],\"zstd\":[\"tower-http/decompression-zstd\"]}}", "resb_0.1.2": "{\"dependencies\":[{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"name\":\"nom\",\"optional\":true,\"req\":\"^7.0.0\"},{\"default_features\":false,\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"}],\"features\":{\"default\":[],\"logging\":[\"dep:log\"],\"serialize\":[\"std\"],\"std\":[],\"text\":[\"dep:indexmap\",\"dep:nom\",\"std\"]}}", "resolv-conf_0.7.6": "{\"dependencies\":[],\"features\":{\"system\":[]}}", "rfc6979_0.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"reset\"],\"name\":\"hmac\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"}],\"features\":{}}", "ring_0.17.14": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.8\"},{\"default_features\":false,\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"getrandom\",\"req\":\"^0.2.10\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(all(any(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), all(target_arch = \\\"arm\\\", target_endian = \\\"little\\\")), any(target_os = \\\"android\\\", target_os = \\\"linux\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_vendor = \\\"apple\\\", any(target_os = \\\"ios\\\", target_os = \\\"macos\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\")))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_os = \\\"windows\\\"))\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\",\"dev_urandom_fallback\"],\"dev_urandom_fallback\":[],\"less-safe-getrandom-custom-or-rdrand\":[],\"less-safe-getrandom-espidf\":[],\"slow_tests\":[],\"std\":[\"alloc\"],\"test_logging\":[],\"unstable-testing-arm-no-hw\":[],\"unstable-testing-arm-no-neon\":[],\"wasm32_unknown_unknown_js\":[\"getrandom/js\"]}}", - "rmcp-macros_0.15.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", - "rmcp_0.15.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"name\":\"axum\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"reqwest\"],\"name\":\"oauth2\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"pastey\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"^9.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^0.15.0\"},{\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"chrono04\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^1.1.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sse-stream\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls-tls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-tls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"dep:schemars\",\"dep:pastey\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"dep:axum\",\"tower\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"]}}", - "rsa_0.9.10": "{\"dependencies\":[{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"base64ct\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"const-oid\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"alloc\",\"oid\"],\"name\":\"digest\",\"req\":\"^0.10.5\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4.1\"},{\"default_features\":false,\"features\":[\"i128\",\"prime\",\"zeroize\"],\"name\":\"num-bigint\",\"package\":\"num-bigint-dig\",\"req\":\"^0.8.6\"},{\"default_features\":false,\"name\":\"num-integer\",\"req\":\"^0.1.39\"},{\"default_features\":false,\"features\":[\"libm\"],\"name\":\"num-traits\",\"req\":\"^0.2.9\"},{\"default_features\":false,\"features\":[\"alloc\",\"pkcs8\"],\"name\":\"pkcs1\",\"req\":\"^0.7.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"pkcs8\",\"req\":\"^0.10.2\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.6.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.184\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.89\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10.5\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.10.5\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.6\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10.6\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha3\",\"req\":\"^0.10.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"digest\",\"rand_core\"],\"name\":\"signature\",\"req\":\">2.0, <2.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"spki\",\"req\":\"^0.7.3\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.1.1\"},{\"features\":[\"alloc\"],\"name\":\"zeroize\",\"req\":\"^1.5\"}],\"features\":{\"default\":[\"std\",\"pem\",\"u64_digit\"],\"getrandom\":[\"rand_core/getrandom\"],\"hazmat\":[],\"nightly\":[\"num-bigint/nightly\"],\"pem\":[\"pkcs1/pem\",\"pkcs8/pem\"],\"pkcs5\":[\"pkcs8/encryption\"],\"serde\":[\"dep:serde\",\"num-bigint/serde\"],\"std\":[\"digest/std\",\"pkcs1/std\",\"pkcs8/std\",\"rand_core/std\",\"signature/std\"],\"u64_digit\":[\"num-bigint/u64_digit\"]}}", + "rmcp-macros_1.7.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"local\":[]}}", + "rmcp_1.7.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"http1\",\"tokio\"],\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.8\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"features\":[\"serde\",\"now\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"client\",\"http1\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"server\",\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"jsonwebtoken\",\"optional\":true,\"req\":\"^10\"},{\"default_features\":false,\"name\":\"oauth2\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"pastey\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"^9.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.13.2\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^1.7.0\"},{\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"chrono04\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^1.1.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sse-stream\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.4\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"which\",\"optional\":true,\"req\":\"^8\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"auth-client-credentials-jwt\":[\"auth\",\"dep:jsonwebtoken\",\"uuid\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"local\":[\"rmcp-macros?/local\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"dep:schemars\",\"dep:pastey\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"tower\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-client-unix-socket\":[\"transport-streamable-http-client\",\"dep:hyper\",\"dep:hyper-util\",\"dep:http-body-util\",\"dep:http\",\"dep:bytes\",\"tokio/net\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"],\"which-command\":[\"transport-child-process\",\"dep:which\"]}}", "rtrb_0.3.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "rust-embed-impl_8.11.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"rust-embed-utils\",\"req\":\"^8.11.0\"},{\"name\":\"shellexpand\",\"optional\":true,\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"derive\",\"parsing\",\"proc-macro\",\"printing\"],\"name\":\"syn\",\"req\":\"^2\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"compression\":[],\"debug-embed\":[],\"deterministic-timestamps\":[],\"include-exclude\":[\"rust-embed-utils/include-exclude\"],\"interpolate-folder-path\":[\"shellexpand\"],\"mime-guess\":[\"rust-embed-utils/mime-guess\"]}}", "rust-embed-utils_8.11.0": "{\"dependencies\":[{\"name\":\"globset\",\"optional\":true,\"req\":\"^0.4.8\"},{\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0.4\"},{\"name\":\"sha2\",\"req\":\"^0.10.5\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"debug-embed\":[],\"include-exclude\":[\"globset\"],\"mime-guess\":[\"mime_guess\"]}}", @@ -1449,6 +1457,8 @@ "rustix_1.1.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"auxvec\",\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.20.3\",\"target\":\"cfg(windows)\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"default\":[\"std\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"thread\",\"linux-raw-sys/io_uring\"],\"linux_4_11\":[],\"linux_5_1\":[\"linux_4_11\"],\"linux_5_11\":[\"linux_5_1\"],\"linux_latest\":[\"linux_5_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"pty\":[\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\"],\"use-libc-auxv\":[]}}", "rustls-native-certs_0.8.3": "{\"dependencies\":[{\"name\":\"openssl-probe\",\"req\":\"^0.2\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"features\":[\"std\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.10\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"kind\":\"dev\",\"name\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"name\":\"schannel\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"security-framework\",\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5\"},{\"kind\":\"dev\",\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18\"}],\"features\":{}}", "rustls-pki-types_1.14.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"crabgrind\",\"req\":\"=0.1.9\",\"target\":\"cfg(all(target_os = \\\"linux\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"dep:zeroize\"],\"default\":[\"alloc\"],\"std\":[\"alloc\"],\"web\":[\"web-time\"]}}", + "rustls-platform-verifier-android_0.1.1": "{\"dependencies\":[],\"features\":{}}", + "rustls-platform-verifier_0.7.0": "{\"dependencies\":[{\"name\":\"android_logger\",\"optional\":true,\"req\":\"^0.15\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"core-foundation\",\"req\":\"^0.10\",\"target\":\"cfg(any(target_vendor = \\\"apple\\\"))\"},{\"name\":\"core-foundation-sys\",\"req\":\"^0.8\",\"target\":\"cfg(any(target_vendor = \\\"apple\\\"))\"},{\"default_features\":false,\"name\":\"jni\",\"req\":\"^0.22\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"default_features\":false,\"name\":\"jni\",\"optional\":true,\"req\":\"^0.22.4\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1.9\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.9\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"req\":\"^0.23.27\"},{\"default_features\":false,\"features\":[\"ring\"],\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"name\":\"rustls-native-certs\",\"req\":\"^0.8\",\"target\":\"cfg(all(unix, not(target_os = \\\"android\\\"), not(target_vendor = \\\"apple\\\"), not(target_arch = \\\"wasm32\\\")))\"},{\"name\":\"rustls-platform-verifier-android\",\"req\":\"^0.1.0\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"security-framework\",\"req\":\"^3.5.0\",\"target\":\"cfg(any(target_vendor = \\\"apple\\\"))\"},{\"name\":\"security-framework-sys\",\"req\":\"^2.15\",\"target\":\"cfg(any(target_vendor = \\\"apple\\\"))\"},{\"default_features\":false,\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\",\"target\":\"cfg(all(unix, not(target_os = \\\"android\\\"), not(target_vendor = \\\"apple\\\"), not(target_arch = \\\"wasm32\\\")))\"},{\"default_features\":false,\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":false,\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"webpki-root-certs\",\"req\":\"^1\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"webpki-root-certs\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"Win32_Foundation\",\"Win32_Security_Cryptography\"],\"name\":\"windows-sys\",\"req\":\">=0.52.0, <0.62.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"cert-logging\":[\"base64\"],\"dbg\":[],\"docsrs\":[\"jni\",\"once_cell\"],\"ffi-testing\":[\"android_logger\",\"rustls/ring\"]}}", "rustls-webpki_0.103.13": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"bzip2\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18.1\"}],\"features\":{\"alloc\":[\"ring?/alloc\",\"pki-types/alloc\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"aws-lc-rs-unstable\":[\"aws-lc-rs\",\"aws-lc-rs/unstable\"],\"default\":[\"std\"],\"ring\":[\"dep:ring\"],\"std\":[\"alloc\",\"pki-types/std\"]}}", "rustls_0.23.36": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"brotli\",\"optional\":true,\"req\":\"^8\"},{\"name\":\"brotli-decompressor\",\"optional\":true,\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"macro_rules_attribute\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"features\":[\"alloc\",\"race\"],\"name\":\"once_cell\",\"req\":\"^1.16\"},{\"features\":[\"alloc\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"pem\",\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"kind\":\"build\",\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.6\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103.5\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17\"},{\"name\":\"zeroize\",\"req\":\"^1.8\"},{\"name\":\"zlib-rs\",\"optional\":true,\"req\":\"^0.5\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"dep:aws-lc-rs\",\"webpki/aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"brotli\":[\"dep:brotli\",\"dep:brotli-decompressor\",\"std\"],\"custom-provider\":[],\"default\":[\"aws_lc_rs\",\"logging\",\"prefer-post-quantum\",\"std\",\"tls12\"],\"fips\":[\"aws_lc_rs\",\"aws-lc-rs?/fips\",\"webpki/aws-lc-rs-fips\"],\"logging\":[\"log\"],\"prefer-post-quantum\":[\"aws_lc_rs\"],\"read_buf\":[\"rustversion\",\"std\"],\"ring\":[\"dep:ring\",\"webpki/ring\"],\"std\":[\"webpki/std\",\"pki-types/std\",\"once_cell/std\"],\"tls12\":[],\"zlib\":[\"dep:zlib-rs\"]}}", "rustversion_1.0.22": "{\"dependencies\":[{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.49\"}],\"features\":{}}", @@ -1509,8 +1519,10 @@ "serial_test_derive_3.3.1": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\">=0.6.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"full\",\"printing\",\"parsing\",\"clone-impls\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"async\":[],\"default\":[],\"file_locks\":[],\"test_logging\":[]}}", "sha1-checked_0.10.0": "{\"dependencies\":[{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"compress\"],\"name\":\"sha1\",\"req\":\"^0.10.6\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.7\"}],\"features\":{\"default\":[\"oid\",\"std\"],\"oid\":[\"digest/oid\",\"sha1/oid\"],\"std\":[\"digest/std\",\"sha1/std\"],\"zeroize\":[\"dep:zeroize\"]}}", "sha1_0.10.6": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"sha1-asm\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"}],\"features\":{\"asm\":[\"sha1-asm\"],\"compress\":[],\"default\":[\"std\"],\"force-soft\":[],\"loongarch64_asm\":[],\"oid\":[\"digest/oid\"],\"std\":[\"digest/std\"]}}", + "sha1_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.11\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"digest/alloc\"],\"default\":[\"alloc\",\"oid\"],\"oid\":[\"digest/oid\"],\"zeroize\":[\"digest/zeroize\"]}}", "sha1_smol_1.0.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"openssl\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.4\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"std\":[\"alloc\"]}}", "sha2_0.10.9": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"sha2-asm\",\"optional\":true,\"req\":\"^0.6.1\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"}],\"features\":{\"asm\":[\"sha2-asm\"],\"asm-aarch64\":[\"asm\"],\"compress\":[],\"default\":[\"std\"],\"force-soft\":[],\"force-soft-compact\":[],\"loongarch64_asm\":[],\"oid\":[\"digest/oid\"],\"std\":[\"digest/std\"]}}", + "sha2_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.11\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"digest/alloc\"],\"default\":[\"alloc\",\"oid\"],\"oid\":[\"digest/oid\"],\"zeroize\":[\"digest/zeroize\"]}}", "sharded-slab_0.1.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"indexmap\",\"req\":\"^1\"},{\"name\":\"lazy_static\",\"req\":\"^1\"},{\"features\":[\"checkpoint\"],\"name\":\"loom\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(loom)\"},{\"features\":[\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.5\",\"target\":\"cfg(loom)\"},{\"kind\":\"dev\",\"name\":\"memory-stats\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"slab\",\"req\":\"^0.4.2\"}],\"features\":{}}", "shared_library_0.1.9": "{\"dependencies\":[{\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2\"}],\"features\":{}}", "shell-words_1.1.1": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", @@ -1520,6 +1532,7 @@ "signal-hook_0.3.18": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^0.7\"},{\"name\":\"signal-hook-registry\",\"req\":\"^1.4\"}],\"features\":{\"channel\":[],\"default\":[\"channel\",\"iterator\"],\"extended-siginfo\":[\"channel\",\"iterator\",\"extended-siginfo-raw\"],\"extended-siginfo-raw\":[\"cc\"],\"iterator\":[\"channel\"]}}", "signature_2.2.0": "{\"dependencies\":[{\"name\":\"derive\",\"optional\":true,\"package\":\"signature_derive\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"digest\",\"optional\":true,\"req\":\"^0.10.6\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"}],\"features\":{\"alloc\":[],\"std\":[\"alloc\",\"rand_core?/std\"]}}", "simd-adler32_0.3.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adler\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"adler32\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"const-generics\":[],\"default\":[\"std\",\"const-generics\"],\"nightly\":[],\"std\":[]}}", + "simd_cesu8_1.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cesu8\",\"req\":\"^1.1.0\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"simdutf8\",\"req\":\"^0.1.4\"}],\"features\":{\"bench\":[],\"default\":[\"std\"],\"nightly\":[],\"std\":[\"simdutf8/std\"]}}", "simdutf8_0.1.5": "{\"dependencies\":[],\"features\":{\"aarch64_neon\":[],\"aarch64_neon_prefetch\":[],\"default\":[\"std\"],\"hints\":[],\"public_imp\":[],\"std\":[]}}", "similar_2.7.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bstr\",\"optional\":true,\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"console\",\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.10.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"name\":\"unicode-segmentation\",\"optional\":true,\"req\":\"^1.7.1\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1\"}],\"features\":{\"bytes\":[\"bstr\",\"text\"],\"default\":[\"text\"],\"inline\":[\"text\"],\"text\":[],\"unicode\":[\"text\",\"unicode-segmentation\",\"bstr?/unicode\",\"bstr?/std\"],\"wasm32_web_time\":[\"web-time\"]}}", "simple_asn1_0.6.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\",\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.47\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\",\"parsing\",\"quickcheck\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\"}],\"features\":{}}", @@ -1533,13 +1546,13 @@ "socket2_0.6.3": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.172\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_System_IO\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\">=0.60, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all\":[]}}", "spin_0.9.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\"},{\"name\":\"lock_api_crate\",\"optional\":true,\"package\":\"lock_api\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"barrier\":[\"mutex\"],\"default\":[\"lock_api\",\"mutex\",\"spin_mutex\",\"rwlock\",\"once\",\"lazy\",\"barrier\"],\"fair_mutex\":[\"mutex\"],\"lazy\":[\"once\"],\"lock_api\":[\"lock_api_crate\"],\"mutex\":[],\"once\":[],\"portable_atomic\":[\"portable-atomic\"],\"rwlock\":[],\"spin_mutex\":[\"mutex\"],\"std\":[],\"ticket_mutex\":[\"mutex\"],\"use_ticket_mutex\":[\"mutex\",\"ticket_mutex\"]}}", "spki_0.7.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"base64ct\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"alloc\":[\"base64ct?/alloc\",\"der/alloc\"],\"arbitrary\":[\"std\",\"dep:arbitrary\",\"der/arbitrary\"],\"base64\":[\"dep:base64ct\"],\"fingerprint\":[\"sha2\"],\"pem\":[\"alloc\",\"der/pem\"],\"std\":[\"der/std\",\"alloc\"]}}", - "sqlx-core_0.8.6": "{\"dependencies\":[{\"name\":\"async-io\",\"optional\":true,\"req\":\"^1.9.0\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.6.3\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bstr\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"bytes\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"optional\":true,\"req\":\"^3\"},{\"name\":\"crossbeam-queue\",\"req\":\"^0.3.2\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"name\":\"event-listener\",\"req\":\"^5.2.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.19\"},{\"name\":\"futures-intrusive\",\"req\":\"^0.5.0\"},{\"name\":\"futures-io\",\"req\":\"^0.3.24\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.19\"},{\"name\":\"hashbrown\",\"req\":\"^0.15.0\"},{\"name\":\"hashlink\",\"req\":\"^0.10.0\"},{\"name\":\"indexmap\",\"req\":\"^2.0\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.3.0\"},{\"name\":\"ipnetwork\",\"optional\":true,\"req\":\"^0.20.0\"},{\"default_features\":false,\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"mac_address\",\"optional\":true,\"req\":\"^1.1.5\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"native-tls\",\"optional\":true,\"req\":\"^0.2.10\"},{\"name\":\"once_cell\",\"req\":\"^1.9.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1.0\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.5.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.26.1\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.15\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.132\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.73\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.0\"},{\"name\":\"smallvec\",\"req\":\"^1.7.0\"},{\"default_features\":false,\"features\":[\"postgres\",\"sqlite\",\"mysql\",\"migrate\",\"macros\",\"time\",\"uuid\"],\"kind\":\"dev\",\"name\":\"sqlx\",\"req\":\"=0.8.6\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.36\"},{\"default_features\":false,\"features\":[\"time\",\"net\",\"sync\",\"fs\",\"io-util\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"fs\"],\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1.8\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"url\",\"req\":\"^2.2.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.1.2\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"_rt-async-std\":[\"async-std\",\"async-io\"],\"_rt-tokio\":[\"tokio\",\"tokio-stream\"],\"_tls-native-tls\":[\"native-tls\"],\"_tls-none\":[],\"_tls-rustls\":[\"rustls\"],\"_tls-rustls-aws-lc-rs\":[\"_tls-rustls\",\"rustls/aws-lc-rs\",\"webpki-roots\"],\"_tls-rustls-ring-native-roots\":[\"_tls-rustls\",\"rustls/ring\",\"rustls-native-certs\"],\"_tls-rustls-ring-webpki\":[\"_tls-rustls\",\"rustls/ring\",\"webpki-roots\"],\"any\":[],\"default\":[],\"json\":[\"serde\",\"serde_json\"],\"migrate\":[\"sha2\",\"crc\"],\"offline\":[\"serde\",\"either/serde\"]}}", - "sqlx-macros-core_0.8.6": "{\"dependencies\":[{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"name\":\"heck\",\"req\":\"^0.5\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"once_cell\",\"req\":\"^1.9.0\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.79\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.26\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.132\"},{\"name\":\"serde_json\",\"req\":\"^1.0.73\"},{\"name\":\"sha2\",\"req\":\"^0.10.0\"},{\"features\":[\"offline\"],\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-mysql\",\"optional\":true,\"req\":\"=0.8.6\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-postgres\",\"optional\":true,\"req\":\"=0.8.6\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-sqlite\",\"optional\":true,\"req\":\"=0.8.6\"},{\"default_features\":false,\"features\":[\"full\",\"derive\",\"parsing\",\"printing\",\"clone-impls\"],\"name\":\"syn\",\"req\":\"^2.0.52\"},{\"default_features\":false,\"features\":[\"time\",\"net\",\"sync\",\"fs\",\"io-util\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"url\",\"req\":\"^2.2.2\"}],\"features\":{\"_rt-async-std\":[\"async-std\",\"sqlx-core/_rt-async-std\"],\"_rt-tokio\":[\"tokio\",\"sqlx-core/_rt-tokio\"],\"_sqlite\":[],\"_tls-native-tls\":[\"sqlx-core/_tls-native-tls\"],\"_tls-rustls-aws-lc-rs\":[\"sqlx-core/_tls-rustls-aws-lc-rs\"],\"_tls-rustls-ring-native-roots\":[\"sqlx-core/_tls-rustls-ring-native-roots\"],\"_tls-rustls-ring-webpki\":[\"sqlx-core/_tls-rustls-ring-webpki\"],\"bigdecimal\":[\"sqlx-core/bigdecimal\",\"sqlx-mysql?/bigdecimal\",\"sqlx-postgres?/bigdecimal\"],\"bit-vec\":[\"sqlx-core/bit-vec\",\"sqlx-postgres?/bit-vec\"],\"chrono\":[\"sqlx-core/chrono\",\"sqlx-mysql?/chrono\",\"sqlx-postgres?/chrono\",\"sqlx-sqlite?/chrono\"],\"default\":[],\"derive\":[],\"ipnet\":[\"sqlx-core/ipnet\",\"sqlx-postgres?/ipnet\"],\"ipnetwork\":[\"sqlx-core/ipnetwork\",\"sqlx-postgres?/ipnetwork\"],\"json\":[\"sqlx-core/json\",\"sqlx-mysql?/json\",\"sqlx-postgres?/json\",\"sqlx-sqlite?/json\"],\"mac_address\":[\"sqlx-core/mac_address\",\"sqlx-postgres?/mac_address\"],\"macros\":[],\"migrate\":[\"sqlx-core/migrate\"],\"mysql\":[\"sqlx-mysql\"],\"postgres\":[\"sqlx-postgres\"],\"rust_decimal\":[\"sqlx-core/rust_decimal\",\"sqlx-mysql?/rust_decimal\",\"sqlx-postgres?/rust_decimal\"],\"sqlite\":[\"_sqlite\",\"sqlx-sqlite/bundled\"],\"sqlite-unbundled\":[\"_sqlite\",\"sqlx-sqlite/unbundled\"],\"time\":[\"sqlx-core/time\",\"sqlx-mysql?/time\",\"sqlx-postgres?/time\",\"sqlx-sqlite?/time\"],\"uuid\":[\"sqlx-core/uuid\",\"sqlx-mysql?/uuid\",\"sqlx-postgres?/uuid\",\"sqlx-sqlite?/uuid\"]}}", - "sqlx-macros_0.8.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.36\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.26\"},{\"features\":[\"any\"],\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"sqlx-macros-core\",\"req\":\"=0.8.6\"},{\"default_features\":false,\"features\":[\"parsing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{\"_rt-async-std\":[\"sqlx-macros-core/_rt-async-std\"],\"_rt-tokio\":[\"sqlx-macros-core/_rt-tokio\"],\"_tls-native-tls\":[\"sqlx-macros-core/_tls-native-tls\"],\"_tls-rustls-aws-lc-rs\":[\"sqlx-macros-core/_tls-rustls-aws-lc-rs\"],\"_tls-rustls-ring-native-roots\":[\"sqlx-macros-core/_tls-rustls-ring-native-roots\"],\"_tls-rustls-ring-webpki\":[\"sqlx-macros-core/_tls-rustls-ring-webpki\"],\"bigdecimal\":[\"sqlx-macros-core/bigdecimal\"],\"bit-vec\":[\"sqlx-macros-core/bit-vec\"],\"chrono\":[\"sqlx-macros-core/chrono\"],\"default\":[],\"derive\":[\"sqlx-macros-core/derive\"],\"ipnet\":[\"sqlx-macros-core/ipnet\"],\"ipnetwork\":[\"sqlx-macros-core/ipnetwork\"],\"json\":[\"sqlx-macros-core/json\"],\"mac_address\":[\"sqlx-macros-core/mac_address\"],\"macros\":[\"sqlx-macros-core/macros\"],\"migrate\":[\"sqlx-macros-core/migrate\"],\"mysql\":[\"sqlx-macros-core/mysql\"],\"postgres\":[\"sqlx-macros-core/postgres\"],\"rust_decimal\":[\"sqlx-macros-core/rust_decimal\"],\"sqlite\":[\"sqlx-macros-core/sqlite\"],\"sqlite-unbundled\":[\"sqlx-macros-core/sqlite-unbundled\"],\"time\":[\"sqlx-macros-core/time\"],\"uuid\":[\"sqlx-macros-core/uuid\"]}}", - "sqlx-mysql_0.8.6": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"serde\"],\"name\":\"bitflags\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"bytes\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"req\":\"^3.0.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"digest\",\"req\":\"^0.10.0\"},{\"name\":\"dotenvy\",\"req\":\"^0.15.5\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.19\"},{\"name\":\"futures-io\",\"req\":\"^0.3.24\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"name\":\"generic-array\",\"req\":\"^0.14.4\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"hkdf\",\"req\":\"^0.12.0\"},{\"default_features\":false,\"name\":\"hmac\",\"req\":\"^0.12.0\"},{\"name\":\"itoa\",\"req\":\"^1.0.1\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"default_features\":false,\"name\":\"md-5\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"once_cell\",\"req\":\"^1.9.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1.0\"},{\"default_features\":false,\"features\":[\"std\",\"std_rng\"],\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"name\":\"rsa\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.26.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.144\"},{\"default_features\":false,\"name\":\"sha1\",\"req\":\"^0.10.1\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.10.0\"},{\"name\":\"smallvec\",\"req\":\"^1.7.0\"},{\"default_features\":false,\"features\":[\"mysql\"],\"kind\":\"dev\",\"name\":\"sqlx\",\"req\":\"=0.8.6\"},{\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"stringprep\",\"req\":\"^0.1.2\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.36\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.1.2\"},{\"default_features\":false,\"name\":\"whoami\",\"req\":\"^1.2.1\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[\"dep:bigdecimal\",\"sqlx-core/bigdecimal\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"json\":[\"sqlx-core/json\",\"serde\"],\"migrate\":[\"sqlx-core/migrate\"],\"offline\":[\"sqlx-core/offline\",\"serde/derive\"],\"rust_decimal\":[\"dep:rust_decimal\",\"rust_decimal/maths\",\"sqlx-core/rust_decimal\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", - "sqlx-postgres_0.8.6": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.6.3\"},{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"req\":\"^3.0.0\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"etcetera\",\"req\":\"^0.8.0\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.19\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"hkdf\",\"req\":\"^0.12.0\"},{\"default_features\":false,\"features\":[\"reset\"],\"name\":\"hmac\",\"req\":\"^0.12.0\"},{\"name\":\"home\",\"req\":\"^0.5.5\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.3.0\"},{\"name\":\"ipnetwork\",\"optional\":true,\"req\":\"^0.20.0\"},{\"name\":\"itoa\",\"req\":\"^1.0.1\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"mac_address\",\"optional\":true,\"req\":\"^1.1.5\"},{\"default_features\":false,\"name\":\"md-5\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"once_cell\",\"req\":\"^1.9.0\"},{\"default_features\":false,\"features\":[\"std\",\"std_rng\"],\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.26.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.144\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"req\":\"^1.0.85\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.10.0\"},{\"features\":[\"serde\"],\"name\":\"smallvec\",\"req\":\"^1.7.0\"},{\"default_features\":false,\"features\":[\"postgres\",\"derive\"],\"kind\":\"dev\",\"name\":\"sqlx\",\"req\":\"=0.8.6\"},{\"features\":[\"json\"],\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"stringprep\",\"req\":\"^0.1.2\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.36\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.1.2\"},{\"default_features\":false,\"name\":\"whoami\",\"req\":\"^1.2.1\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[\"dep:bigdecimal\",\"dep:num-bigint\",\"sqlx-core/bigdecimal\"],\"bit-vec\":[\"dep:bit-vec\",\"sqlx-core/bit-vec\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"ipnet\":[\"dep:ipnet\",\"sqlx-core/ipnet\"],\"ipnetwork\":[\"dep:ipnetwork\",\"sqlx-core/ipnetwork\"],\"json\":[\"sqlx-core/json\"],\"mac_address\":[\"dep:mac_address\",\"sqlx-core/mac_address\"],\"migrate\":[\"sqlx-core/migrate\"],\"offline\":[\"sqlx-core/offline\"],\"rust_decimal\":[\"dep:rust_decimal\",\"rust_decimal/maths\",\"sqlx-core/rust_decimal\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", - "sqlx-sqlite_0.8.6": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"default_features\":false,\"features\":[\"async\"],\"name\":\"flume\",\"req\":\"^0.11.0\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.19\"},{\"name\":\"futures-executor\",\"req\":\"^0.3.19\"},{\"name\":\"futures-intrusive\",\"req\":\"^0.5.0\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"features\":[\"pkg-config\",\"vcpkg\",\"unlock_notify\"],\"name\":\"libsqlite3-sys\",\"req\":\"^0.30.1\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1.0\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.5.5\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.145\"},{\"name\":\"serde_urlencoded\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"macros\",\"runtime-tokio\",\"tls-none\",\"sqlite\"],\"kind\":\"dev\",\"name\":\"sqlx\",\"req\":\"=0.8.6\"},{\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.36\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"url\",\"req\":\"^2.2.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.1.2\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bundled\":[\"libsqlite3-sys/bundled\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"json\":[\"sqlx-core/json\",\"serde\"],\"migrate\":[\"sqlx-core/migrate\"],\"offline\":[\"sqlx-core/offline\",\"serde\"],\"preupdate-hook\":[\"libsqlite3-sys/preupdate_hook\"],\"regexp\":[\"dep:regex\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"unbundled\":[\"libsqlite3-sys/buildtime_bindgen\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", - "sqlx_0.8.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.52\"},{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.12\"},{\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"dotenvy\",\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.19\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.30.1\"},{\"features\":[\"bundled-sqlcipher\"],\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.30.1\",\"target\":\"cfg(sqlite_test_sqlcipher)\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"rand_xoshiro\",\"req\":\"^0.6.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.132\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.73\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"sqlx-macros\",\"optional\":true,\"req\":\"=0.8.6\"},{\"name\":\"sqlx-mysql\",\"optional\":true,\"req\":\"=0.8.6\"},{\"name\":\"sqlx-postgres\",\"optional\":true,\"req\":\"=0.8.6\"},{\"name\":\"sqlx-sqlite\",\"optional\":true,\"req\":\"=0.8.6\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"kind\":\"dev\",\"name\":\"time_\",\"package\":\"time\",\"req\":\"^0.3.2\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.15.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.53\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.2.2\"}],\"features\":{\"_rt-async-std\":[],\"_rt-tokio\":[],\"_sqlite\":[],\"_unstable-all-types\":[\"bigdecimal\",\"rust_decimal\",\"json\",\"time\",\"chrono\",\"ipnet\",\"ipnetwork\",\"mac_address\",\"uuid\",\"bit-vec\",\"bstr\"],\"all-databases\":[\"mysql\",\"sqlite\",\"postgres\",\"any\"],\"any\":[\"sqlx-core/any\",\"sqlx-mysql?/any\",\"sqlx-postgres?/any\",\"sqlx-sqlite?/any\"],\"bigdecimal\":[\"sqlx-core/bigdecimal\",\"sqlx-macros?/bigdecimal\",\"sqlx-mysql?/bigdecimal\",\"sqlx-postgres?/bigdecimal\"],\"bit-vec\":[\"sqlx-core/bit-vec\",\"sqlx-macros?/bit-vec\",\"sqlx-postgres?/bit-vec\"],\"bstr\":[\"sqlx-core/bstr\"],\"chrono\":[\"sqlx-core/chrono\",\"sqlx-macros?/chrono\",\"sqlx-mysql?/chrono\",\"sqlx-postgres?/chrono\",\"sqlx-sqlite?/chrono\"],\"default\":[\"any\",\"macros\",\"migrate\",\"json\"],\"derive\":[\"sqlx-macros/derive\"],\"ipnet\":[\"sqlx-core/ipnet\",\"sqlx-macros?/ipnet\",\"sqlx-postgres?/ipnet\"],\"ipnetwork\":[\"sqlx-core/ipnetwork\",\"sqlx-macros?/ipnetwork\",\"sqlx-postgres?/ipnetwork\"],\"json\":[\"sqlx-core/json\",\"sqlx-macros?/json\",\"sqlx-mysql?/json\",\"sqlx-postgres?/json\",\"sqlx-sqlite?/json\"],\"mac_address\":[\"sqlx-core/mac_address\",\"sqlx-macros?/mac_address\",\"sqlx-postgres?/mac_address\"],\"macros\":[\"derive\",\"sqlx-macros/macros\"],\"migrate\":[\"sqlx-core/migrate\",\"sqlx-macros?/migrate\",\"sqlx-mysql?/migrate\",\"sqlx-postgres?/migrate\",\"sqlx-sqlite?/migrate\"],\"mysql\":[\"sqlx-mysql\",\"sqlx-macros?/mysql\"],\"postgres\":[\"sqlx-postgres\",\"sqlx-macros?/postgres\"],\"regexp\":[\"sqlx-sqlite?/regexp\"],\"runtime-async-std\":[\"_rt-async-std\",\"sqlx-core/_rt-async-std\",\"sqlx-macros?/_rt-async-std\"],\"runtime-async-std-native-tls\":[\"runtime-async-std\",\"tls-native-tls\"],\"runtime-async-std-rustls\":[\"runtime-async-std\",\"tls-rustls-ring\"],\"runtime-tokio\":[\"_rt-tokio\",\"sqlx-core/_rt-tokio\",\"sqlx-macros?/_rt-tokio\"],\"runtime-tokio-native-tls\":[\"runtime-tokio\",\"tls-native-tls\"],\"runtime-tokio-rustls\":[\"runtime-tokio\",\"tls-rustls-ring\"],\"rust_decimal\":[\"sqlx-core/rust_decimal\",\"sqlx-macros?/rust_decimal\",\"sqlx-mysql?/rust_decimal\",\"sqlx-postgres?/rust_decimal\"],\"sqlite\":[\"_sqlite\",\"sqlx-sqlite/bundled\",\"sqlx-macros?/sqlite\"],\"sqlite-preupdate-hook\":[\"sqlx-sqlite/preupdate-hook\"],\"sqlite-unbundled\":[\"_sqlite\",\"sqlx-sqlite/unbundled\",\"sqlx-macros?/sqlite-unbundled\"],\"time\":[\"sqlx-core/time\",\"sqlx-macros?/time\",\"sqlx-mysql?/time\",\"sqlx-postgres?/time\",\"sqlx-sqlite?/time\"],\"tls-native-tls\":[\"sqlx-core/_tls-native-tls\",\"sqlx-macros?/_tls-native-tls\"],\"tls-none\":[],\"tls-rustls\":[\"tls-rustls-ring\"],\"tls-rustls-aws-lc-rs\":[\"sqlx-core/_tls-rustls-aws-lc-rs\",\"sqlx-macros?/_tls-rustls-aws-lc-rs\"],\"tls-rustls-ring\":[\"tls-rustls-ring-webpki\"],\"tls-rustls-ring-native-roots\":[\"sqlx-core/_tls-rustls-ring-native-roots\",\"sqlx-macros?/_tls-rustls-ring-native-roots\"],\"tls-rustls-ring-webpki\":[\"sqlx-core/_tls-rustls-ring-webpki\",\"sqlx-macros?/_tls-rustls-ring-webpki\"],\"uuid\":[\"sqlx-core/uuid\",\"sqlx-macros?/uuid\",\"sqlx-mysql?/uuid\",\"sqlx-postgres?/uuid\",\"sqlx-sqlite?/uuid\"]}}", + "sqlx-core_0.9.0": "{\"dependencies\":[{\"name\":\"async-fs\",\"optional\":true,\"req\":\"^2.1\"},{\"default_features\":false,\"features\":[\"async-io\"],\"name\":\"async-global-executor\",\"optional\":true,\"req\":\"^3.1\"},{\"name\":\"async-io\",\"optional\":true,\"req\":\"^2.4.1\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.13\"},{\"name\":\"async-task\",\"optional\":true,\"req\":\"^4.7.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bstr\",\"optional\":true,\"req\":\"^1.0.1\"},{\"name\":\"bytes\",\"req\":\"^1.2.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"optional\":true,\"req\":\"^3\"},{\"name\":\"crossbeam-queue\",\"req\":\"^0.3.2\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"name\":\"event-listener\",\"req\":\"^5.2.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"name\":\"futures-intrusive\",\"req\":\"^0.5.0\"},{\"name\":\"futures-io\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"name\":\"hashbrown\",\"req\":\"^0.16.0\"},{\"name\":\"hashlink\",\"req\":\"^0.11.0\"},{\"name\":\"indexmap\",\"req\":\"^2.0\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.3.0\"},{\"name\":\"ipnetwork\",\"optional\":true,\"req\":\"^0.21.1\"},{\"default_features\":false,\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"mac_address\",\"optional\":true,\"req\":\"^1.1.5\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.5.0\"},{\"name\":\"native-tls\",\"optional\":true,\"req\":\"^0.2.10\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.36.0\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.24\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.219\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.142\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.0\"},{\"name\":\"smallvec\",\"req\":\"^1.13.1\"},{\"default_features\":false,\"name\":\"smol\",\"optional\":true,\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"req\":\"^2.0.18\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.47\"},{\"default_features\":false,\"features\":[\"time\",\"net\",\"sync\",\"fs\",\"io-util\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.25.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25.0\"},{\"features\":[\"fs\"],\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1.8\"},{\"name\":\"toml\",\"optional\":true,\"req\":\"^0.8.16\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"url\",\"req\":\"^2.2.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.12.1\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"_rt-async-global-executor\":[\"async-global-executor\",\"_rt-async-io\",\"_rt-async-task\"],\"_rt-async-io\":[\"async-io\",\"async-fs\"],\"_rt-async-std\":[\"async-std\",\"_rt-async-io\"],\"_rt-async-task\":[\"async-task\"],\"_rt-smol\":[\"smol\",\"_rt-async-io\",\"_rt-async-task\"],\"_rt-tokio\":[\"tokio\",\"tokio-stream\"],\"_tls-native-tls\":[\"native-tls\"],\"_tls-none\":[],\"_tls-rustls\":[\"rustls\"],\"_tls-rustls-aws-lc-rs\":[\"_tls-rustls\",\"rustls/aws-lc-rs\",\"webpki-roots\"],\"_tls-rustls-ring-native-roots\":[\"_tls-rustls\",\"rustls/ring\",\"rustls-native-certs\"],\"_tls-rustls-ring-webpki\":[\"_tls-rustls\",\"rustls/ring\",\"webpki-roots\"],\"_unstable-doc\":[\"sqlx-toml\"],\"any\":[],\"default\":[],\"json\":[\"serde\",\"serde_json\"],\"migrate\":[\"sha2\",\"crc\"],\"offline\":[\"serde\",\"either/serde\"],\"sqlx-toml\":[\"serde\",\"toml/parse\"]}}", + "sqlx-macros-core_0.9.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"async-io\"],\"name\":\"async-global-executor\",\"optional\":true,\"req\":\"^3.1\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.13\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"name\":\"heck\",\"req\":\"^0.5\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.83\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.219\"},{\"name\":\"serde_json\",\"req\":\"^1.0.142\"},{\"name\":\"sha2\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"smol\",\"optional\":true,\"req\":\"^2.0\"},{\"features\":[\"offline\"],\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"default_features\":false,\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-mysql\",\"optional\":true,\"req\":\"=0.9.0\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-postgres\",\"optional\":true,\"req\":\"=0.9.0\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-sqlite\",\"optional\":true,\"req\":\"=0.9.0\"},{\"default_features\":false,\"features\":[\"full\",\"derive\",\"parsing\",\"printing\",\"clone-impls\"],\"name\":\"syn\",\"req\":\"^2.0.87\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"optional\":true,\"req\":\"^2.0.18\"},{\"default_features\":false,\"features\":[\"time\",\"net\",\"sync\",\"fs\",\"io-util\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.25.0\"},{\"name\":\"url\",\"req\":\"^2.2.2\"}],\"features\":{\"_rt-async-global-executor\":[\"async-global-executor\",\"sqlx-core/_rt-async-global-executor\"],\"_rt-async-std\":[\"async-std\",\"sqlx-core/_rt-async-std\"],\"_rt-smol\":[\"smol\",\"sqlx-core/_rt-smol\"],\"_rt-tokio\":[\"tokio\",\"sqlx-core/_rt-tokio\"],\"_sqlite\":[],\"_tls-native-tls\":[\"sqlx-core/_tls-native-tls\"],\"_tls-rustls-aws-lc-rs\":[\"sqlx-core/_tls-rustls-aws-lc-rs\"],\"_tls-rustls-ring-native-roots\":[\"sqlx-core/_tls-rustls-ring-native-roots\"],\"_tls-rustls-ring-webpki\":[\"sqlx-core/_tls-rustls-ring-webpki\"],\"bigdecimal\":[\"sqlx-core/bigdecimal\",\"sqlx-mysql?/bigdecimal\",\"sqlx-postgres?/bigdecimal\"],\"bit-vec\":[\"sqlx-core/bit-vec\",\"sqlx-postgres?/bit-vec\"],\"chrono\":[\"sqlx-core/chrono\",\"sqlx-mysql?/chrono\",\"sqlx-postgres?/chrono\",\"sqlx-sqlite?/chrono\"],\"default\":[],\"derive\":[],\"ipnet\":[\"sqlx-core/ipnet\",\"sqlx-postgres?/ipnet\"],\"ipnetwork\":[\"sqlx-core/ipnetwork\",\"sqlx-postgres?/ipnetwork\"],\"json\":[\"sqlx-core/json\",\"sqlx-mysql?/json\",\"sqlx-postgres?/json\",\"sqlx-sqlite?/json\"],\"mac_address\":[\"sqlx-core/mac_address\",\"sqlx-postgres?/mac_address\"],\"macros\":[\"thiserror\"],\"migrate\":[\"sqlx-core/migrate\"],\"mysql\":[\"sqlx-mysql\"],\"mysql-rsa\":[\"mysql\",\"sqlx-mysql/rsa\"],\"postgres\":[\"sqlx-postgres\"],\"rust_decimal\":[\"sqlx-core/rust_decimal\",\"sqlx-mysql?/rust_decimal\",\"sqlx-postgres?/rust_decimal\"],\"sqlite\":[\"_sqlite\",\"sqlx-sqlite/bundled\"],\"sqlite-load-extension\":[\"sqlx-sqlite/load-extension\"],\"sqlite-unbundled\":[\"_sqlite\",\"sqlx-sqlite/unbundled\"],\"sqlx-toml\":[\"sqlx-core/sqlx-toml\",\"sqlx-sqlite?/sqlx-toml\"],\"time\":[\"sqlx-core/time\",\"sqlx-mysql?/time\",\"sqlx-postgres?/time\",\"sqlx-sqlite?/time\"],\"uuid\":[\"sqlx-core/uuid\",\"sqlx-mysql?/uuid\",\"sqlx-postgres?/uuid\",\"sqlx-sqlite?/uuid\"]}}", + "sqlx-macros_0.9.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.83\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"features\":[\"any\"],\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"name\":\"sqlx-macros-core\",\"req\":\"=0.9.0\"},{\"default_features\":false,\"features\":[\"parsing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.87\"}],\"features\":{\"_rt-async-global-executor\":[\"sqlx-macros-core/_rt-async-global-executor\"],\"_rt-async-std\":[\"sqlx-macros-core/_rt-async-std\"],\"_rt-smol\":[\"sqlx-macros-core/_rt-smol\"],\"_rt-tokio\":[\"sqlx-macros-core/_rt-tokio\"],\"_tls-native-tls\":[\"sqlx-macros-core/_tls-native-tls\"],\"_tls-rustls-aws-lc-rs\":[\"sqlx-macros-core/_tls-rustls-aws-lc-rs\"],\"_tls-rustls-ring-native-roots\":[\"sqlx-macros-core/_tls-rustls-ring-native-roots\"],\"_tls-rustls-ring-webpki\":[\"sqlx-macros-core/_tls-rustls-ring-webpki\"],\"bigdecimal\":[\"sqlx-macros-core/bigdecimal\"],\"bit-vec\":[\"sqlx-macros-core/bit-vec\"],\"chrono\":[\"sqlx-macros-core/chrono\"],\"default\":[],\"derive\":[\"sqlx-macros-core/derive\"],\"ipnet\":[\"sqlx-macros-core/ipnet\"],\"ipnetwork\":[\"sqlx-macros-core/ipnetwork\"],\"json\":[\"sqlx-macros-core/json\"],\"mac_address\":[\"sqlx-macros-core/mac_address\"],\"macros\":[\"sqlx-macros-core/macros\"],\"migrate\":[\"sqlx-macros-core/migrate\"],\"mysql\":[\"sqlx-macros-core/mysql\"],\"mysql-rsa\":[\"sqlx-macros-core/mysql-rsa\"],\"postgres\":[\"sqlx-macros-core/postgres\"],\"rust_decimal\":[\"sqlx-macros-core/rust_decimal\"],\"sqlite\":[\"sqlx-macros-core/sqlite\"],\"sqlite-load-extension\":[\"sqlx-macros-core/sqlite-load-extension\"],\"sqlite-unbundled\":[\"sqlx-macros-core/sqlite-unbundled\"],\"sqlx-toml\":[\"sqlx-macros-core/sqlx-toml\"],\"time\":[\"sqlx-macros-core/time\"],\"uuid\":[\"sqlx-macros-core/uuid\"]}}", + "sqlx-mysql_0.9.0": "{\"dependencies\":[{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"bytes\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"optional\":true,\"req\":\"^3.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"digest\",\"req\":\"^0.11.2\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"generic-array\",\"req\":\"^0.14.4\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.10.1\"},{\"name\":\"rsa\",\"optional\":true,\"req\":\"^0.10.0-rc.18\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.36.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.219\"},{\"default_features\":false,\"name\":\"sha1\",\"req\":\"^0.11.0\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.11.0\"},{\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"req\":\"^2.0.18\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.47\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.12.1\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[\"dep:bigdecimal\",\"sqlx-core/bigdecimal\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"default\":[],\"json\":[\"sqlx-core/json\",\"serde\"],\"migrate\":[\"sqlx-core/migrate\",\"dep:crc\"],\"offline\":[\"sqlx-core/offline\",\"serde/derive\",\"bitflags/serde\"],\"rsa\":[\"dep:rand\",\"dep:rsa\"],\"rust_decimal\":[\"dep:rust_decimal\",\"rust_decimal/maths\",\"sqlx-core/rust_decimal\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", + "sqlx-postgres_0.9.0": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"optional\":true,\"req\":\"^3.0.0\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"etcetera\",\"req\":\"^0.11.0\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"hkdf\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"name\":\"hmac\",\"req\":\"^0.13.0\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.3.0\"},{\"name\":\"ipnetwork\",\"optional\":true,\"req\":\"^0.21.1\"},{\"name\":\"itoa\",\"req\":\"^1.0.5\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"mac_address\",\"optional\":true,\"req\":\"^1.1.5\"},{\"default_features\":false,\"name\":\"md-5\",\"req\":\"^0.11.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.5.0\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4.3\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"name\":\"rand\",\"req\":\"^0.10.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.36.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.219\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.142\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.11.0\"},{\"name\":\"smallvec\",\"req\":\"^1.13.1\"},{\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"name\":\"stringprep\",\"req\":\"^0.1.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"req\":\"^2.0.18\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.47\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.12.1\"},{\"default_features\":false,\"name\":\"whoami\",\"req\":\"^2.0.2\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[\"dep:bigdecimal\",\"dep:num-bigint\",\"sqlx-core/bigdecimal\"],\"bit-vec\":[\"dep:bit-vec\",\"sqlx-core/bit-vec\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"ipnet\":[\"dep:ipnet\",\"sqlx-core/ipnet\"],\"ipnetwork\":[\"dep:ipnetwork\",\"sqlx-core/ipnetwork\"],\"json\":[\"dep:serde\",\"dep:serde_json\",\"sqlx-core/json\"],\"mac_address\":[\"dep:mac_address\",\"sqlx-core/mac_address\"],\"migrate\":[\"sqlx-core/migrate\",\"dep:crc\"],\"offline\":[\"json\",\"sqlx-core/offline\",\"smallvec/serde\"],\"rust_decimal\":[\"dep:rust_decimal\",\"rust_decimal/maths\",\"sqlx-core/rust_decimal\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", + "sqlx-sqlite_0.9.0": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"default_features\":false,\"features\":[\"async\"],\"name\":\"flume\",\"req\":\"^0.12.0\"},{\"name\":\"form_urlencoded\",\"req\":\"^1.2.2\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"name\":\"futures-executor\",\"req\":\"^0.3.32\"},{\"name\":\"futures-intrusive\",\"req\":\"^0.5.0\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"pkg-config\",\"vcpkg\"],\"name\":\"libsqlite3-sys\",\"req\":\">=0.30.1, <0.38.0\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.6.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.219\"},{\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"req\":\"^2.0.18\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.47\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"url\",\"req\":\"^2.2.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.12.1\"}],\"features\":{\"_unstable-all-sqlite-features\":[\"deserialize\",\"load-extension\",\"preupdate-hook\",\"unlock-notify\"],\"_unstable-all-types\":[\"json\",\"chrono\",\"time\",\"uuid\"],\"_unstable-docs\":[\"bundled\",\"any\",\"_unstable-all-types\",\"_unstable-all-sqlite-features\"],\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[],\"bundled\":[\"libsqlite3-sys/bundled\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"deserialize\":[],\"json\":[\"sqlx-core/json\",\"serde\"],\"load-extension\":[],\"migrate\":[\"sqlx-core/migrate\"],\"offline\":[\"sqlx-core/offline\",\"serde\"],\"preupdate-hook\":[\"libsqlite3-sys/preupdate_hook\"],\"regexp\":[\"dep:regex\"],\"rust_decimal\":[],\"sqlx-toml\":[\"sqlx-core/sqlx-toml\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"unbundled\":[\"libsqlite3-sys/buildtime_bindgen\"],\"unlock-notify\":[\"libsqlite3-sys/unlock_notify\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", + "sqlx_0.9.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13\"},{\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.37.0\"},{\"features\":[\"bundled-sqlcipher\"],\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.37.0\",\"target\":\"cfg(sqlite_test_sqlcipher)\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.6\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10.1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.219\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.142\"},{\"features\":[\"migrate\"],\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"name\":\"sqlx-macros\",\"optional\":true,\"req\":\"=0.9.0\"},{\"default_features\":false,\"name\":\"sqlx-mysql\",\"optional\":true,\"req\":\"=0.9.0\"},{\"name\":\"sqlx-postgres\",\"optional\":true,\"req\":\"=0.9.0\"},{\"name\":\"sqlx-sqlite\",\"optional\":true,\"req\":\"=0.9.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"kind\":\"dev\",\"name\":\"time_\",\"package\":\"time\",\"req\":\"^0.3.47\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.53\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.2.2\"}],\"features\":{\"_rt-async-global-executor\":[],\"_rt-async-std\":[],\"_rt-smol\":[],\"_rt-tokio\":[],\"_sqlite\":[],\"_unstable-all-types\":[\"bigdecimal\",\"rust_decimal\",\"json\",\"time\",\"chrono\",\"ipnet\",\"ipnetwork\",\"mac_address\",\"uuid\",\"bit-vec\",\"bstr\"],\"_unstable-docs\":[\"all-databases\",\"_unstable-all-types\",\"sqlx-sqlite/_unstable-docs\"],\"all-databases\":[\"mysql\",\"sqlite\",\"postgres\",\"any\"],\"any\":[\"sqlx-core/any\",\"sqlx-mysql?/any\",\"sqlx-postgres?/any\",\"sqlx-sqlite?/any\"],\"bigdecimal\":[\"sqlx-core/bigdecimal\",\"sqlx-macros?/bigdecimal\",\"sqlx-mysql?/bigdecimal\",\"sqlx-postgres?/bigdecimal\"],\"bit-vec\":[\"sqlx-core/bit-vec\",\"sqlx-macros?/bit-vec\",\"sqlx-postgres?/bit-vec\"],\"bstr\":[\"sqlx-core/bstr\"],\"chrono\":[\"sqlx-core/chrono\",\"sqlx-macros?/chrono\",\"sqlx-mysql?/chrono\",\"sqlx-postgres?/chrono\",\"sqlx-sqlite?/chrono\"],\"default\":[\"any\",\"macros\",\"migrate\",\"json\"],\"derive\":[\"sqlx-macros/derive\"],\"ipnet\":[\"sqlx-core/ipnet\",\"sqlx-macros?/ipnet\",\"sqlx-postgres?/ipnet\"],\"ipnetwork\":[\"sqlx-core/ipnetwork\",\"sqlx-macros?/ipnetwork\",\"sqlx-postgres?/ipnetwork\"],\"json\":[\"sqlx-core/json\",\"sqlx-macros?/json\",\"sqlx-mysql?/json\",\"sqlx-postgres?/json\",\"sqlx-sqlite?/json\"],\"mac_address\":[\"sqlx-core/mac_address\",\"sqlx-macros?/mac_address\",\"sqlx-postgres?/mac_address\"],\"macros\":[\"derive\",\"sqlx-macros/macros\",\"sqlx-core/offline\",\"sqlx-mysql?/offline\",\"sqlx-postgres?/offline\",\"sqlx-sqlite?/offline\"],\"migrate\":[\"sqlx-core/migrate\",\"sqlx-macros?/migrate\",\"sqlx-mysql?/migrate\",\"sqlx-postgres?/migrate\",\"sqlx-sqlite?/migrate\"],\"mysql\":[\"sqlx-mysql\",\"sqlx-macros?/mysql\"],\"mysql-rsa\":[\"mysql\",\"sqlx-mysql/rsa\",\"sqlx-macros?/mysql-rsa\"],\"postgres\":[\"sqlx-postgres\",\"sqlx-macros?/postgres\"],\"regexp\":[\"sqlx-sqlite?/regexp\"],\"runtime-async-global-executor\":[\"_rt-async-global-executor\",\"sqlx-core/_rt-async-global-executor\",\"sqlx-macros?/_rt-async-global-executor\"],\"runtime-async-std\":[\"_rt-async-std\",\"sqlx-core/_rt-async-std\",\"sqlx-macros?/_rt-async-std\"],\"runtime-smol\":[\"_rt-smol\",\"sqlx-core/_rt-smol\",\"sqlx-macros?/_rt-smol\"],\"runtime-tokio\":[\"_rt-tokio\",\"sqlx-core/_rt-tokio\",\"sqlx-macros?/_rt-tokio\"],\"rust_decimal\":[\"sqlx-core/rust_decimal\",\"sqlx-macros?/rust_decimal\",\"sqlx-mysql?/rust_decimal\",\"sqlx-postgres?/rust_decimal\"],\"sqlite\":[\"sqlite-bundled\",\"sqlite-deserialize\",\"sqlite-load-extension\",\"sqlite-unlock-notify\"],\"sqlite-bundled\":[\"_sqlite\",\"sqlx-sqlite/bundled\",\"sqlx-macros?/sqlite\"],\"sqlite-deserialize\":[\"sqlx-sqlite/deserialize\"],\"sqlite-load-extension\":[\"sqlx-sqlite/load-extension\",\"sqlx-macros?/sqlite-load-extension\"],\"sqlite-preupdate-hook\":[\"sqlx-sqlite/preupdate-hook\"],\"sqlite-unbundled\":[\"_sqlite\",\"sqlx-sqlite/unbundled\",\"sqlx-macros?/sqlite-unbundled\"],\"sqlite-unlock-notify\":[\"sqlx-sqlite/unlock-notify\"],\"sqlx-toml\":[\"sqlx-core/sqlx-toml\",\"sqlx-macros?/sqlx-toml\",\"sqlx-sqlite?/sqlx-toml\"],\"time\":[\"sqlx-core/time\",\"sqlx-macros?/time\",\"sqlx-mysql?/time\",\"sqlx-postgres?/time\",\"sqlx-sqlite?/time\"],\"tls-native-tls\":[\"sqlx-core/_tls-native-tls\",\"sqlx-macros?/_tls-native-tls\"],\"tls-none\":[],\"tls-rustls\":[\"tls-rustls-ring\"],\"tls-rustls-aws-lc-rs\":[\"sqlx-core/_tls-rustls-aws-lc-rs\",\"sqlx-macros?/_tls-rustls-aws-lc-rs\"],\"tls-rustls-ring\":[\"tls-rustls-ring-webpki\"],\"tls-rustls-ring-native-roots\":[\"sqlx-core/_tls-rustls-ring-native-roots\",\"sqlx-macros?/_tls-rustls-ring-native-roots\"],\"tls-rustls-ring-webpki\":[\"sqlx-core/_tls-rustls-ring-webpki\",\"sqlx-macros?/_tls-rustls-ring-webpki\"],\"uuid\":[\"sqlx-core/uuid\",\"sqlx-macros?/uuid\",\"sqlx-mysql?/uuid\",\"sqlx-postgres?/uuid\",\"sqlx-sqlite?/uuid\"]}}", "sse-stream_0.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"features\":[\"tracing\"],\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.8\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"client\",\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"stream\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"io\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"}],\"features\":{\"default\":[],\"tracing\":[\"dep:tracing\"]}}", "stable_deref_trait_1.2.1": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "starlark_0.13.0": "{\"dependencies\":[{\"features\":[\"bumpalo\",\"num-bigint\"],\"name\":\"allocative\",\"req\":\"^0.3.4\"},{\"name\":\"anyhow\",\"req\":\"^1.0.65\"},{\"name\":\"bumpalo\",\"req\":\"^3.8\"},{\"name\":\"cmp_any\",\"req\":\"^0.8.1\"},{\"name\":\"debugserver-types\",\"req\":\"^0.5.0\"},{\"name\":\"derivative\",\"req\":\"^2.2\"},{\"features\":[\"full\"],\"name\":\"derive_more\",\"req\":\"^1.0.0\"},{\"name\":\"display_container\",\"req\":\"^0.9.0\"},{\"name\":\"dupe\",\"req\":\"^0.9.0\"},{\"name\":\"either\",\"req\":\"^1.8\"},{\"name\":\"erased-serde\",\"req\":\"^0.3.12\"},{\"features\":[\"raw\"],\"name\":\"hashbrown\",\"req\":\"^0.14.3\"},{\"name\":\"inventory\",\"req\":\"^0.3.8\"},{\"name\":\"itertools\",\"req\":\"^0.13.0\"},{\"name\":\"maplit\",\"req\":\"^1.0.2\"},{\"name\":\"memoffset\",\"req\":\"^0.6.4\"},{\"name\":\"num-bigint\",\"req\":\"^0.4.3\"},{\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"once_cell\",\"req\":\"^1.8\"},{\"name\":\"paste\",\"req\":\"^1.0\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"name\":\"regex\",\"req\":\"^1.5.4\"},{\"name\":\"rustyline\",\"req\":\"^14.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"starlark_derive\",\"req\":\"^0.13.0\"},{\"name\":\"starlark_map\",\"req\":\"^0.13.0\"},{\"name\":\"starlark_syntax\",\"req\":\"^0.13.0\"},{\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"name\":\"strsim\",\"req\":\"^0.10.0\"},{\"name\":\"textwrap\",\"req\":\"^0.11\"},{\"name\":\"thiserror\",\"req\":\"^1.0.36\"}],\"features\":{}}", @@ -1659,7 +1672,7 @@ "ts-rs_11.1.0": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"bigdecimal\",\"optional\":true,\"req\":\">=0.0.13, <0.5\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"dprint-plugin-typescript\",\"optional\":true,\"req\":\"=0.95\"},{\"name\":\"heapless\",\"optional\":true,\"req\":\">=0.7, <0.9\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ordered-float\",\"optional\":true,\"req\":\">=3, <6\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"sync\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40\"},{\"name\":\"ts-rs-macros\",\"req\":\"=11.1.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"bigdecimal-impl\":[\"bigdecimal\"],\"bson-uuid-impl\":[\"bson\"],\"bytes-impl\":[\"bytes\"],\"chrono-impl\":[\"chrono\"],\"default\":[\"serde-compat\"],\"format\":[\"dprint-plugin-typescript\"],\"heapless-impl\":[\"heapless\"],\"import-esm\":[],\"indexmap-impl\":[\"indexmap\"],\"no-serde-warnings\":[\"ts-rs-macros/no-serde-warnings\"],\"ordered-float-impl\":[\"ordered-float\"],\"semver-impl\":[\"semver\"],\"serde-compat\":[\"ts-rs-macros/serde-compat\"],\"serde-json-impl\":[\"serde_json\"],\"smol_str-impl\":[\"smol_str\"],\"tokio-impl\":[\"tokio\"],\"url-impl\":[\"url\"],\"uuid-impl\":[\"uuid\"]}}", "two-face_0.5.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cargo-lock\",\"req\":\"^10.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.44.3\"},{\"default_features\":false,\"features\":[\"read\"],\"kind\":\"dev\",\"name\":\"object\",\"req\":\"^0.36.7\"},{\"name\":\"serde\",\"req\":\"^1.0.228\"},{\"name\":\"serde_derive\",\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"similar\",\"req\":\"^2.7.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26.3\"},{\"default_features\":false,\"features\":[\"dump-load\",\"parsing\"],\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"default_features\":false,\"features\":[\"html\"],\"kind\":\"dev\",\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.23\"},{\"default_features\":false,\"features\":[\"std\",\"xxhash64\"],\"kind\":\"dev\",\"name\":\"twox-hash\",\"req\":\"^2.1.2\"}],\"features\":{\"default\":[\"syntect-onig\"],\"syntect-default-fancy\":[\"syntect-fancy\",\"syntect/default-fancy\"],\"syntect-default-onig\":[\"syntect-onig\",\"syntect/default-onig\"],\"syntect-fancy\":[\"syntect/regex-fancy\"],\"syntect-onig\":[\"syntect/regex-onig\"]}}", "type-map_0.5.1": "{\"dependencies\":[{\"name\":\"rustc-hash\",\"req\":\"^2\"}],\"features\":{}}", - "typenum_1.19.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"force_unix_path_separator\":[],\"i128\":[],\"no_std\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}", + "typenum_1.20.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"i128\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}", "uds_windows_1.1.0": "{\"dependencies\":[{\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"name\":\"tempfile\",\"req\":\"^3\",\"target\":\"cfg(windows)\"},{\"features\":[\"winsock2\",\"ws2def\",\"minwinbase\",\"ntdef\",\"processthreadsapi\",\"handleapi\",\"ws2tcpip\",\"winbase\"],\"name\":\"winapi\",\"req\":\"^0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "uname_0.1.1": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\"}],\"features\":{}}", "unarray_0.1.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"test-strategy\",\"req\":\"^0.2\"}],\"features\":{}}", @@ -1714,6 +1727,7 @@ "wasm-encoder_0.244.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"default_features\":false,\"name\":\"leb128fmt\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2.0\"},{\"default_features\":false,\"features\":[\"simd\",\"simd\"],\"name\":\"wasmparser\",\"optional\":true,\"req\":\"^0.244.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"wasmprinter\",\"req\":\"^0.244.0\"}],\"features\":{\"component-model\":[\"wasmparser?/component-model\"],\"default\":[\"std\",\"component-model\"],\"std\":[\"wasmparser?/std\"]}}", "wasm-metadata_0.244.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"name\":\"auditable-serde\",\"optional\":true,\"req\":\"^0.8.0\"},{\"features\":[\"derive\"],\"name\":\"clap\",\"optional\":true,\"req\":\"^4.0.0\"},{\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"serde\"],\"name\":\"indexmap\",\"req\":\"^2.7.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.166\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0.166\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"spdx\",\"optional\":true,\"req\":\"^0.10.1\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"features\":[\"std\",\"component-model\"],\"name\":\"wasm-encoder\",\"req\":\"^0.244.0\"},{\"default_features\":false,\"features\":[\"simd\",\"std\",\"component-model\",\"hash-collections\"],\"name\":\"wasmparser\",\"req\":\"^0.244.0\"}],\"features\":{\"default\":[\"oci\",\"serde\"],\"oci\":[\"dep:auditable-serde\",\"dep:flate2\",\"dep:url\",\"dep:spdx\",\"dep:serde_json\",\"serde\"],\"serde\":[\"dep:serde_derive\",\"dep:serde\"]}}", "wasm-streams_0.4.2": "{\"dependencies\":[{\"features\":[\"io\",\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"features\":[\"futures\"],\"kind\":\"dev\",\"name\":\"gloo-timers\",\"req\":\"^0.3.0\"},{\"name\":\"js-sys\",\"req\":\"^0.3.72\"},{\"kind\":\"dev\",\"name\":\"pin-project\",\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.95\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.45\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.45\"},{\"features\":[\"AbortSignal\",\"QueuingStrategy\",\"ReadableStream\",\"ReadableStreamType\",\"ReadableWritablePair\",\"ReadableStreamByobReader\",\"ReadableStreamReaderMode\",\"ReadableStreamReadResult\",\"ReadableStreamByobRequest\",\"ReadableStreamDefaultReader\",\"ReadableByteStreamController\",\"ReadableStreamGetReaderOptions\",\"ReadableStreamDefaultController\",\"StreamPipeOptions\",\"TransformStream\",\"TransformStreamDefaultController\",\"Transformer\",\"UnderlyingSink\",\"UnderlyingSource\",\"WritableStream\",\"WritableStreamDefaultController\",\"WritableStreamDefaultWriter\"],\"name\":\"web-sys\",\"req\":\"^0.3.72\"},{\"features\":[\"console\",\"AbortSignal\",\"ErrorEvent\",\"PromiseRejectionEvent\",\"Response\",\"ReadableStream\",\"Window\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3.72\"}],\"features\":{}}", + "wasm-streams_0.5.0": "{\"dependencies\":[{\"features\":[\"io\",\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"features\":[\"futures\"],\"kind\":\"dev\",\"name\":\"gloo-timers\",\"req\":\"^0.3.0\"},{\"name\":\"js-sys\",\"req\":\"^0.3.85\"},{\"kind\":\"dev\",\"name\":\"pin-project\",\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.108\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.58\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.58\"},{\"features\":[\"AbortSignal\",\"QueuingStrategy\",\"ReadableStream\",\"ReadableStreamType\",\"ReadableWritablePair\",\"ReadableStreamByobReader\",\"ReadableStreamReaderMode\",\"ReadableStreamReadResult\",\"ReadableStreamByobRequest\",\"ReadableStreamDefaultReader\",\"ReadableByteStreamController\",\"ReadableStreamGetReaderOptions\",\"ReadableStreamDefaultController\",\"StreamPipeOptions\",\"TransformStream\",\"TransformStreamDefaultController\",\"Transformer\",\"UnderlyingSink\",\"UnderlyingSource\",\"WritableStream\",\"WritableStreamDefaultController\",\"WritableStreamDefaultWriter\"],\"name\":\"web-sys\",\"req\":\"^0.3.85\"},{\"features\":[\"console\",\"AbortSignal\",\"ErrorEvent\",\"PromiseRejectionEvent\",\"Response\",\"ReadableStream\",\"Window\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3.85\"}],\"features\":{}}", "wasmparser_0.244.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"name\":\"bitflags\",\"req\":\"^2.4.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.2\"},{\"default_features\":false,\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.7.0\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.17\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.13.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.3\"},{\"default_features\":false,\"name\":\"semver\",\"optional\":true,\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.166\"}],\"features\":{\"component-model\":[\"dep:semver\"],\"default\":[\"std\",\"validate\",\"serde\",\"features\",\"component-model\",\"hash-collections\",\"simd\"],\"features\":[],\"hash-collections\":[\"dep:hashbrown\",\"dep:indexmap\"],\"prefer-btree-collections\":[],\"serde\":[\"dep:serde\",\"indexmap?/serde\",\"hashbrown?/serde\"],\"simd\":[],\"std\":[\"indexmap?/std\"],\"validate\":[]}}", "wayland-backend_0.3.12": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"concat-idents\",\"req\":\"^1.1\"},{\"name\":\"downcast-rs\",\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"raw-window-handle\",\"optional\":true,\"req\":\"^0.5.0\"},{\"features\":[\"event\",\"fs\",\"net\",\"process\"],\"name\":\"rustix\",\"req\":\"^1.0.2\"},{\"name\":\"rwh_06\",\"optional\":true,\"package\":\"raw-window-handle\",\"req\":\"^0.6.0\"},{\"name\":\"scoped-tls\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"scoped-tls\",\"req\":\"^1.0\"},{\"features\":[\"union\",\"const_generics\",\"const_new\"],\"name\":\"smallvec\",\"req\":\"^1.9\"},{\"name\":\"wayland-sys\",\"req\":\"^0.31.8\"}],\"features\":{\"client_system\":[\"wayland-sys/client\",\"dep:scoped-tls\"],\"dlopen\":[\"wayland-sys/dlopen\"],\"server_system\":[\"wayland-sys/server\",\"dep:scoped-tls\"]}}", "wayland-client_0.31.12": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3.16\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"event\"],\"name\":\"rustix\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2\"},{\"name\":\"wayland-backend\",\"req\":\"^0.3.12\"},{\"name\":\"wayland-scanner\",\"req\":\"^0.31.8\"}],\"features\":{}}", @@ -1725,12 +1739,12 @@ "web-time_1.1.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"name\":\"js-sys\",\"req\":\"^0.3.20\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"macro\"],\"kind\":\"dev\",\"name\":\"pollster\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"^0.2.70\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"features\":[\"WorkerGlobalScope\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"features\":[\"CssStyleDeclaration\",\"Document\",\"Element\",\"HtmlTableElement\",\"HtmlTableRowElement\",\"Performance\",\"Window\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", "webbrowser_1.0.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-files\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"actix-web\",\"req\":\"^4\"},{\"name\":\"core-foundation\",\"req\":\"^0.10\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"crossbeam-channel\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.0\"},{\"name\":\"jni\",\"req\":\"^0.21\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"ndk-context\",\"req\":\"^0.1\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"ndk-glue\",\"req\":\">=0.3, <=0.7\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"objc2\",\"req\":\"^0.6\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"NSDictionary\",\"NSString\",\"NSURL\"],\"name\":\"objc2-foundation\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^0.10\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"url\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"Window\"],\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"disable-wsl\":[],\"hardened\":[],\"wasm-console\":[\"web-sys/console\"]}}", "webpki-root-certs_1.0.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"}],\"features\":{}}", - "webpki-roots_0.26.11": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"parent\",\"package\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.102\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"yasna\",\"req\":\"^0.5.2\"}],\"features\":{}}", "webpki-roots_1.0.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.3\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"yasna\",\"req\":\"^0.5.2\"}],\"features\":{}}", "weezl_0.1.12": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.12\"},{\"default_features\":false,\"features\":[\"macros\",\"io-util\",\"net\",\"rt\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"compat\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.6.2\"}],\"features\":{\"alloc\":[],\"async\":[\"futures\",\"std\"],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "which_6.0.3": "{\"dependencies\":[{\"name\":\"either\",\"req\":\"^1.9.0\"},{\"name\":\"home\",\"req\":\"^0.5.9\",\"target\":\"cfg(any(windows, unix, target_os = \\\"redox\\\"))\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.10.2\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"req\":\"^0.38.30\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\", target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.9.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"features\":[\"kernel\"],\"name\":\"winsafe\",\"req\":\"^0.0.19\",\"target\":\"cfg(windows)\"}],\"features\":{\"regex\":[\"dep:regex\"],\"tracing\":[\"dep:tracing\"]}}", "which_8.0.0": "{\"dependencies\":[{\"name\":\"env_home\",\"optional\":true,\"req\":\"^0.1.0\",\"target\":\"cfg(any(windows, unix, target_os = \\\"redox\\\"))\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.10.2\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"optional\":true,\"req\":\"^1.0.5\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\", target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.9.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"features\":[\"kernel\"],\"name\":\"winsafe\",\"optional\":true,\"req\":\"^0.0.19\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"real-sys\"],\"real-sys\":[\"dep:env_home\",\"dep:rustix\",\"dep:winsafe\"],\"regex\":[\"dep:regex\"],\"tracing\":[\"dep:tracing\"]}}", "whoami_1.6.1": "{\"dependencies\":[{\"name\":\"libredox\",\"req\":\"^0.1.1\",\"target\":\"cfg(all(target_os = \\\"redox\\\", not(target_arch = \\\"wasm32\\\")))\"},{\"name\":\"wasite\",\"req\":\"^0.1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\"))\"},{\"features\":[\"Navigator\",\"Document\",\"Window\",\"Location\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\"), not(daku)))\"}],\"features\":{\"default\":[\"web\"],\"web\":[\"web-sys\"]}}", + "whoami_2.1.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.186\",\"target\":\"cfg(all(any(target_vendor = \\\"apple\\\", target_os = \\\"linux\\\", target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\", target_os = \\\"openbsd\\\", target_os = \\\"illumos\\\", target_os = \\\"hurd\\\"), not(target_arch = \\\"wasm32\\\")))\"},{\"default_features\":false,\"features\":[\"call\"],\"name\":\"libredox\",\"optional\":true,\"req\":\"^0.1.16\",\"target\":\"cfg(all(target_os = \\\"redox\\\", not(target_arch = \\\"wasm32\\\")))\"},{\"default_features\":false,\"features\":[\"SCDynamicStore\",\"SCDynamicStoreCopySpecific\"],\"name\":\"objc2-system-configuration\",\"optional\":true,\"req\":\"^0.3.2\",\"target\":\"cfg(target_vendor = \\\"apple\\\")\"},{\"default_features\":false,\"name\":\"wasite\",\"optional\":true,\"req\":\"^1.0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"Navigator\",\"Document\",\"Window\",\"Location\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3.85\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\"), not(daku)))\"}],\"features\":{\"default\":[\"std\",\"wasi-wasite\",\"wasm-web\"],\"force-stub\":[],\"std\":[\"dep:libc\",\"dep:libredox\",\"dep:objc2-system-configuration\",\"libc?/std\",\"web-sys?/std\"],\"wasi-wasite\":[\"dep:wasite\"],\"wasm-web\":[\"dep:web-sys\"]}}", "widestring_1.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"debugger_test\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"debugger_test_parser\",\"req\":\"^0.1\"},{\"features\":[\"Win32_System_Diagnostics_Debug\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.59\"}],\"features\":{\"alloc\":[],\"debugger_visualizer\":[\"alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "wildcard_0.3.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.10.5\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.203\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.14\"},{\"kind\":\"dev\",\"name\":\"wildmatch\",\"req\":\"^2.3.4\"}],\"features\":{\"fatal-warnings\":[]}}", "wildmatch_2.6.1": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"ntest\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.10.2\"},{\"kind\":\"dev\",\"name\":\"regex-lite\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", @@ -1867,112 +1881,112 @@ "zvariant_utils_2.1.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.81\"},{\"name\":\"quote\",\"req\":\"^1.0.36\"},{\"features\":[\"extra-traits\",\"full\"],\"name\":\"syn\",\"req\":\"^2.0.64\"}],\"features\":{}}" }, "@@rules_rs+//rs/experimental/toolchains:module_extension.bzl%toolchains": { - "cargo-1.93.0-aarch64-apple-darwin.tar.xz": "6443909350322ad07f09bb5edfd9ff29268e6fe88c7d78bfba7a5e254248dc25", - "cargo-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "387832b989c8eb96c9ebd66402a87962167633bc6d91a49ffb8c7903c45f3476", - "cargo-1.93.0-aarch64-pc-windows-msvc.tar.xz": "155bff7a16aa7054e7ed7c3a82e362d4b302b3882d751b823e06ff63ae3f103d", - "cargo-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "5998940b8b97286bb67facb1a85535eeb3d4d7a61e36a85e386e5c0c5cfe5266", - "cargo-1.93.0-x86_64-apple-darwin.tar.xz": "95a47c5ed797c35419908f04188d8b7de09946e71073c4b72632b16f5b10dfae", - "cargo-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "f19766837559f90476508140cb95cc708220012ec00a854fa9f99187b1f246b6", - "cargo-1.93.0-x86_64-pc-windows-msvc.tar.xz": "e59c5e2baa9ec17261f2cda6676ebf7b68b21a860e3f7451c4d964728951da75", - "cargo-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "c23de3ae709ff33eed5e4ae59d1f9bcd75fa4dbaa9fb92f7b06bfb534b8db880", - "clippy-1.93.0-aarch64-apple-darwin.tar.xz": "0b6e943a8d12be0e68575acf59c9ea102daf795055fcbbf862b0bfd35ec40039", - "clippy-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "296949f49be2de77ce9d1c5023a5f0a58e28e329eec03642a3da0e175a67beeb", - "clippy-1.93.0-aarch64-pc-windows-msvc.tar.xz": "07bcf2edb88cdf5ead2f02e4a8493e9b0ef935a31253fac6f9f3378d8023f113", - "clippy-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "872ae6d68d625946d281b91d928332e6b74f6ab269b6af842338df4338805a60", - "clippy-1.93.0-x86_64-apple-darwin.tar.xz": "e6d0b1afb9607c14a1172d09ee194a032bbb3e48af913d55c5a473e0559eddde", - "clippy-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "b6f1f7264ed6943c59dedfb9531fbadcc3c0fcf273c940a63d58898b14a1060f", - "clippy-1.93.0-x86_64-pc-windows-msvc.tar.xz": "25fb103390bf392980b4689ac09b2ec2ab4beefb7022a983215b613ad05eab57", - "clippy-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "793108977514b15c0f45ade28ae35c58b05370cb0f22e89bd98fdfa61eabf55d", - "rust-analyzer-1.93.0-aarch64-apple-darwin.tar.xz": "8a09a46d45277678b2d112eef61736e03e78dfa6c506e187df176b904659e5a7", - "rust-analyzer-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "1fb48fadbaea6da36cfcd6b72122eeaa6ab025c9c82441777cf913108f73c115", - "rust-analyzer-1.93.0-aarch64-pc-windows-msvc.tar.xz": "558100ab62fe22d552be43c1bf166fef057ff2f3eb0d786cf5fe509a08701f37", - "rust-analyzer-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "b2e7890cf5953f572eb05d7328c086fa60d4f16e5d301d35c52b3f58b88a8316", - "rust-analyzer-1.93.0-x86_64-apple-darwin.tar.xz": "3cb7975d4fd17840e2980c9d864755f801ee9e7594de886a5816c73b07594508", - "rust-analyzer-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "4471db6393b7380624899482aae99612fd1cf509ae36d0c68292077c2104127d", - "rust-analyzer-1.93.0-x86_64-pc-windows-msvc.tar.xz": "f4c1f8f120f48974cb1e0cea01a955ce52cdfa0f1db0355c5fb5a2deeca8188a", - "rust-analyzer-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "33fcd377be3b5ffdd95977c3d0219f63725c18b6c8b53fb5be0418962a84738c", - "rust-src-1.93.0.tar.xz": "0e7b9acd5debfeffef3741dc8f6edf137d70426a0027d6b190cc8cfb0a1ac23c", - "rust-std-1.93.0-aarch64-apple-darwin.tar.xz": "8603c63715349636ed85b4fe716c4e827a727918c840e54aff5b243cedadf19b", - "rust-std-1.93.0-aarch64-apple-ios-macabi.tar.xz": "24d47e615ce101869ff452a572a6b77ed7cf70f2454d0b50892ac849e8c7ac4d", - "rust-std-1.93.0-aarch64-apple-ios-sim.tar.xz": "d1d5e2d1b79206f2cc9fb7f6a2958cfe0f4bbc9147fda8dbc3608aa4be5e6816", - "rust-std-1.93.0-aarch64-apple-ios.tar.xz": "49228e70387773a71cf144509baf39979ab2cdb604340fff64b483ab41f61617", - "rust-std-1.93.0-aarch64-linux-android.tar.xz": "59c16648d9a29c07f63a1749cae6b73078f20fef1206c5e0f81c784ae8796cdb", - "rust-std-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "9a270d50eaaacc7cb1925565a8b65ff831686aa1592b7034bb9848d7f2a9738d", - "rust-std-1.93.0-aarch64-pc-windows-msvc.tar.xz": "f7bd3d25baf3643c8769b8c4d2e6cde45bb25042fac698e0daf19fc9f58f8568", - "rust-std-1.93.0-aarch64-unknown-fuchsia.tar.xz": "d1e46c443a9607603c810942e99a95a1acfb105d1312426b468ff68febaabf77", - "rust-std-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "84e82ff52c39c64dfd0e1c2d58fd3d5309d1d2502378131544c0d486b44af20a", - "rust-std-1.93.0-aarch64-unknown-linux-musl.tar.xz": "bab885a87da586040064064bd1c314d707164d8dc0fefee39d59be7f15ce6f7d", - "rust-std-1.93.0-aarch64-unknown-none-softfloat.tar.xz": "0f6305daf658a7d6c0efd075859cb60432c13b82e7ecee0d097074e4e1873391", - "rust-std-1.93.0-aarch64-unknown-none.tar.xz": "3cf1aa3309a8903e89bb20537113155ca4e88844c8cc9c34c43865d5ce5a6192", - "rust-std-1.93.0-aarch64-unknown-uefi.tar.xz": "317b0af124e0e124bd76b8e5a2fb0c600279177d0bed9c841a3202df2d0f7f8e", - "rust-std-1.93.0-arm-linux-androideabi.tar.xz": "d010b26fc88e28a93cc94ea6ca5d2c90efed7f9846fae1e40db7c183b50575e2", - "rust-std-1.93.0-arm-unknown-linux-gnueabi.tar.xz": "deedc54ffce099781986eed4aec0843866f1bf72904ab0f5cdb115b9c7af540e", - "rust-std-1.93.0-arm-unknown-linux-gnueabihf.tar.xz": "89e44e042bc1241b3999191c385fec8433d60a5a9fc373583cd3b2d9408d5074", - "rust-std-1.93.0-arm-unknown-linux-musleabi.tar.xz": "641a17acb5104637d4dc9c4be022a7927ae721eb08759fea96ecfaf5c60be4dc", - "rust-std-1.93.0-arm-unknown-linux-musleabihf.tar.xz": "94a92b454bf3b0aab046b257f555ccb08f16dc2dc281bea6a4ef17ea8f58cbdc", - "rust-std-1.93.0-armv7-linux-androideabi.tar.xz": "e295f26bb219a7a4ebb5c2e07fedfebb075be6830aaf910c742a57cd21018b6d", - "rust-std-1.93.0-armv7-unknown-linux-gnueabi.tar.xz": "8a7bd5227c78294864095edb07837ff32ff6c07cd1a4a418f9bcc3ebd7e79874", - "rust-std-1.93.0-armv7-unknown-linux-gnueabihf.tar.xz": "f015f9b2d588454a9dc62942ab2e800d82c602e4eab6f89f8213419491bcd203", - "rust-std-1.93.0-armv7-unknown-linux-musleabi.tar.xz": "d15d24c9fb7c15243e1341cea53590002df271060118914bd0efcda8ccbd0731", - "rust-std-1.93.0-armv7-unknown-linux-musleabihf.tar.xz": "a2e5ec22ed35fb51a503d1e10b37447b0fa7333f079585bc0b6a2eb599de43f3", - "rust-std-1.93.0-i686-linux-android.tar.xz": "68fd86f62dd63221717d1741210f0f5cf75da7a1e32eed5a46b1e67c9d9430e1", - "rust-std-1.93.0-i686-pc-windows-gnu.tar.xz": "cb613d5d1eb245e8a1f4c0b25f93c2997cd06c1cc3fc202155f2997aebf44d4d", - "rust-std-1.93.0-i686-pc-windows-gnullvm.tar.xz": "0f713dc252a6de706519fe6cdaab6d66aaf1b555133b536cc0ab28061aa4269c", - "rust-std-1.93.0-i686-pc-windows-msvc.tar.xz": "33dc1951e2dc21bd05361160d52f496eecf48e0b95df5083172698b1cd5b9a3f", - "rust-std-1.93.0-i686-unknown-freebsd.tar.xz": "67718aae1381879fdcca5699051eb87e0cda3d2fd0fe75e306ba0948b79df7db", - "rust-std-1.93.0-i686-unknown-linux-gnu.tar.xz": "b8b7020a61418b95c1ea26badaf8db6979778e28dbadddc81fb5010fe27c935b", - "rust-std-1.93.0-i686-unknown-linux-musl.tar.xz": "867e54b3e89dc0b6d2b7a538a80443add6c3990bb4bd2260dea2ed98a0dc9870", - "rust-std-1.93.0-i686-unknown-uefi.tar.xz": "929fd484b08d5b2077ff864f5f2d24b51a78f1b6e837b9eab9d7e8fb7f31adce", - "rust-std-1.93.0-powerpc-unknown-linux-gnu.tar.xz": "e851c0fa3e726ce3f7139c5803198a1aa9723594394734ac9e373c93d92e5ea3", - "rust-std-1.93.0-powerpc64-unknown-linux-gnu.tar.xz": "f729bb7d95705e12a92eb072e494b93d8822ca40aa4802ca780b0cf33b56d401", - "rust-std-1.93.0-powerpc64le-unknown-linux-gnu.tar.xz": "d209ac698a69ca9b9035adb97a0ed8e60a08db52960198c3e03b9ee714c1a46b", - "rust-std-1.93.0-powerpc64le-unknown-linux-musl.tar.xz": "34b98d5eca2fdbd6ba41b0faf14160ef1ebd038f6ecaa264d318ad33263e1cf1", - "rust-std-1.93.0-riscv32imc-unknown-none-elf.tar.xz": "71af84c81241cbc7811b267927990be025f30d7d3dc55df4b56da7ac250f7c78", - "rust-std-1.93.0-riscv64gc-unknown-linux-gnu.tar.xz": "b769fb6c9f3e0419a6bd0b7b79f9191bbd7a48a9f243b23eb7d135711aa6de1b", - "rust-std-1.93.0-riscv64gc-unknown-linux-musl.tar.xz": "a7ced602573d814d875d69022e026c1ccb520b4b2de9d430ddfd0966ec6c9643", - "rust-std-1.93.0-riscv64gc-unknown-none-elf.tar.xz": "842f72913f288a0c76601438e67ccd88c816dbf187587928e48bf8b9ce74cbf3", - "rust-std-1.93.0-s390x-unknown-linux-gnu.tar.xz": "41a65db45a288eb3eedb187b366f132d5b3615767de7ce994b123b342ac7a848", - "rust-std-1.93.0-thumbv6m-none-eabi.tar.xz": "be3f8aad5680dabb203300847dcbbabc15729170ba5c3a9c499efae4df410a9e", - "rust-std-1.93.0-thumbv7em-none-eabi.tar.xz": "8f93eefca39c0da417feddab64775f862c72bbe80da11914dcf47babef8f1644", - "rust-std-1.93.0-thumbv7em-none-eabihf.tar.xz": "a3b6914b966ac93dbe7531016d5f570b189445603c43614a60e0b9ea12674bd3", - "rust-std-1.93.0-thumbv7m-none-eabi.tar.xz": "cfa6227214f3ae58c06b36547c5bd6f0f6787764afa48cfa4ff3488264deab6c", - "rust-std-1.93.0-thumbv8m.main-none-eabi.tar.xz": "5f24df0aa8322561125575e365be7ad13a5bb26cf73c7fc9a3f4bcfa58e0febc", - "rust-std-1.93.0-thumbv8m.main-none-eabihf.tar.xz": "906b07580be2df277cced2b56bc03cb565b758c382bf3e82cbd8375b459815dd", - "rust-std-1.93.0-wasm32-unknown-emscripten.tar.xz": "63cdbb1ea7f353060539c00f7346f4f5fb0d6f09899cacddc1f172ef07c4af8b", - "rust-std-1.93.0-wasm32-unknown-unknown.tar.xz": "3100cb920ddac646943243f0eddd331128836b9161dd5f7b0a6c76375d39cc5e", - "rust-std-1.93.0-wasm32-wasip1-threads.tar.xz": "439c65dea31e855f0258632b6d19435ba8a80561297fa6dc6be48048c5cd1871", - "rust-std-1.93.0-wasm32-wasip1.tar.xz": "075de970ef865678dad258f1566d7cfe76a594698e9bf93dd69fa5cfdfcf1a6f", - "rust-std-1.93.0-wasm32-wasip2.tar.xz": "0ef01bb552036ab44456f5505015b13c88d3694629804d7af46452c8b0a48f8c", - "rust-std-1.93.0-x86_64-apple-darwin.tar.xz": "f112d41c8a31794f0f561d37fe77010ed0b405fa70284a2910891869d8c52418", - "rust-std-1.93.0-x86_64-apple-ios-macabi.tar.xz": "a543dd545747d372d973ace8b485a13603ce96c110c7ae734d605e45f6e162c5", - "rust-std-1.93.0-x86_64-apple-ios.tar.xz": "e151013b9bc5990e178285a33e62bae7700d8c48c06e97771abb1643aa907d75", - "rust-std-1.93.0-x86_64-linux-android.tar.xz": "dc05ca79d9fecc5ce3643adb9c6f89fd35c8e1d7146bf9b62e30bad41f9fb6a7", - "rust-std-1.93.0-x86_64-pc-windows-gnu.tar.xz": "a07c6ab596fad15ca7acd63ee7f2a5fea93fd421179252067e309c2aa0b2021b", - "rust-std-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "ef6cf0977bc5aa4bbd594afb9df4ba76fdd4f0fc5685cddbefff49ceed202a91", - "rust-std-1.93.0-x86_64-pc-windows-msvc.tar.xz": "2593e29af0b8def34ceb1185b8e85bd93a9e0d3b0c108d704c1b31370c50a48c", - "rust-std-1.93.0-x86_64-unknown-freebsd.tar.xz": "51b2feaff7c2d28633504ed92ab442a55d112e6a2bf09c91188f00dbaf03378a", - "rust-std-1.93.0-x86_64-unknown-fuchsia.tar.xz": "41f0f3eb96cedfc13ab5fd4f15065063f262d035c1f71d96c42587acdacdbabe", - "rust-std-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "a849a418d0f27e69573e41763c395e924a0b98c16fcdc55599c1c79c27c1c777", - "rust-std-1.93.0-x86_64-unknown-linux-musl.tar.xz": "874658d2ced1ed2b9bf66c148b78a2e10cad475d0a4db32e68a08900905b89b8", - "rust-std-1.93.0-x86_64-unknown-netbsd.tar.xz": "aad63193af89772031f9a85f193afc0b15f8e6d4a9a4983c5f5d3802f69a89e8", - "rust-std-1.93.0-x86_64-unknown-none.tar.xz": "01dcca7ae4b7e82fbfa399adb5e160afaa13143e5a17e1e0737c38cf07365fb3", - "rust-std-1.93.0-x86_64-unknown-uefi.tar.xz": "ec4e439d9485ce752b56999e8e41ed82373fc833a005cf2531c6f7ef7e785392", - "rustc-1.93.0-aarch64-apple-darwin.tar.xz": "092be03c02b44c405dab1232541c84f32b2d9e8295747568c3d531dd137221dc", - "rustc-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "d3bc0cdaf157e20b1f23e510b5e3c4c6e9117d08f5284c04dee60aecff1bc851", - "rustc-1.93.0-aarch64-pc-windows-msvc.tar.xz": "a3ac1a8e411de8470f71b366f89d187718c431526912b181692ed0a18c56c7ad", - "rustc-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "1a9045695892ec08d8e9751bf7cf7db71fe27a6202dd12ce13aca48d0602dbde", - "rustc-1.93.0-x86_64-apple-darwin.tar.xz": "594bb293f0a4f444656cf8dec2149fcb979c606260efee9e09bcf8c9c6ed6ae7", - "rustc-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "0cdaa8de66f5ce21d1ea73917efc5c64f408bda49f678ddde19465ced9d5ec63", - "rustc-1.93.0-x86_64-pc-windows-msvc.tar.xz": "fa17677eee0d83eb055b309953184bf87ba634923d8897f860cda65d55c6e350", - "rustc-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "00c6e6740ea6a795e33568cd7514855d58408a1180cd820284a7bbf7c46af715", - "rustfmt-1.93.0-aarch64-apple-darwin.tar.xz": "0dd1faedf0768ef362f4aae4424b34e8266f2b9cf5e76ea4fcaf780220b363a0", - "rustfmt-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "5888827e7fbd7d59930870b4856fce8d6d8fca5e02f6535f8ae3d7ad0ccf2d4a", - "rustfmt-1.93.0-aarch64-pc-windows-msvc.tar.xz": "24eed108489567133bbfe40c8eacda1567be55fae4c526911b39eb33eb27a6cb", - "rustfmt-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "92e1acb45ae642136258b4dabb39302af2d53c83e56ebd5858bc969f9e5c141a", - "rustfmt-1.93.0-x86_64-apple-darwin.tar.xz": "c8453b4c5758eb39423042ffa9c23ed6128cbed2b15b581e5e1192c9cc0b1d4e", - "rustfmt-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "47167e9e78db9be4503a060dee02f4df2cda252da32175dbf44331f965a747b9", - "rustfmt-1.93.0-x86_64-pc-windows-msvc.tar.xz": "5becc7c2dba4b9ab5199012cad30829235a7f7fb5d85a238697e8f0e44cbd9af", - "rustfmt-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "7f81f6c17d11a7fda5b4e1b111942fb3b23d30dcec767e13e340ebfb762a5e33" + "cargo-1.95.0-aarch64-apple-darwin.tar.xz": "6c2ffed8e1ac9cf4dc9e80f282a869a6b237a153e7c55cca039d33de29d80aaf", + "cargo-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "14683a8e0b0ee8afdd7e2896fc8e91a7ff0ba55c2e20912e639eea498f1e1d10", + "cargo-1.95.0-aarch64-pc-windows-msvc.tar.xz": "e645b30fa035a18aa12d28b699052014c7efa9dd4a33dabd223f0d16b5fa28e8", + "cargo-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "7c070aeba9bbf12073646995a03f36c346bb5f541d0078ba6d9dc2a7adaaf6af", + "cargo-1.95.0-x86_64-apple-darwin.tar.xz": "e2e1131ade2dddc0d779e0ab3a6a990085c7a654951235742823c3a1ce0f190f", + "cargo-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "239c098b9878ad01ad5e0feeee377e6ee3311bc1534b1fb5eb630489d463bab2", + "cargo-1.95.0-x86_64-pc-windows-msvc.tar.xz": "cab2606cb2d0aa31c55d50512fe07a9f15e893227566fbeb448306760cd0d2bf", + "cargo-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "e74edd2cf7d0f1f1383b4f00eb90c843750bc489e2ccf7214e6476678a907425", + "clippy-1.95.0-aarch64-apple-darwin.tar.xz": "fd183baa023d0c4e0c5b8184226e2d4c85126adf156cb1f3a726ec593bba8d62", + "clippy-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "b81d0fe05c4ec514aefaffdf0649b175a2f82572163202c17531358f196b6168", + "clippy-1.95.0-aarch64-pc-windows-msvc.tar.xz": "44c1b7ada72aa8f3fcaceb37a3899665bc9b160c2fea77879c8ecb65a9e97eba", + "clippy-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "fb021e0c0fc2238be9266d7614f4a26bc372544c4cba3528d729ab24ad229fc9", + "clippy-1.95.0-x86_64-apple-darwin.tar.xz": "e47367f6b1489d74cbba93b387310adcb82e27a51e44b2c6ff543eb4f199fe32", + "clippy-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "9358dfc3b831a5f4b3a3a0016da734b4ef5e78c84ca8f148f56a9c126515ebfa", + "clippy-1.95.0-x86_64-pc-windows-msvc.tar.xz": "ddc151d6f58c6658b7380292ecaef36e62d063bbdbf7f5802669810575bb5b75", + "clippy-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "ac779bc9839dd47180806b133e4e2563c4a34716284cd5b8fede8ef289f452ca", + "rust-analyzer-1.95.0-aarch64-apple-darwin.tar.xz": "11231fc6574301b94bd379af4ef409caef7c65b877bcecf2b227dc0d74aa0ec7", + "rust-analyzer-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "6b2c0820957fdf0e3026f51fe85ad3cef94d5948e29cc83d2221f65dafc7a16b", + "rust-analyzer-1.95.0-aarch64-pc-windows-msvc.tar.xz": "92958624f23d4b0980748ac9e6d67f6f67a868f8224e8c6240e3f84145e2d805", + "rust-analyzer-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "b37e5b9aad624e54228254f98a710ee19ad464fe7ada93ef12e20c87886a0047", + "rust-analyzer-1.95.0-x86_64-apple-darwin.tar.xz": "6cd111900e13fd19b188c5d8844b34136af3967066c0ea2914ce5c3508296c85", + "rust-analyzer-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "01bd05a6b990ad37907ff26e8c285c5ba8b7e674fabd0e264fc7f7aa04d963c5", + "rust-analyzer-1.95.0-x86_64-pc-windows-msvc.tar.xz": "ba58e349f5e8b0ef13735c48d4ad8d8c7664472f8403f3c9d97b291bd54a7638", + "rust-analyzer-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "a9d71c6e7427c45afcd846a8b34a3e3301ae7a0e91a2bcf929326af77a7dc68e", + "rust-src-1.95.0.tar.xz": "67b09138c8db96afc4bbfc69ea771ac9a091fd777698acb43f6dfd9fb7dea363", + "rust-std-1.95.0-aarch64-apple-darwin.tar.xz": "9b30089b0f767cb91b2190ffec55a9beeb2a21a1405d8da0f664d7e09d08e6d8", + "rust-std-1.95.0-aarch64-apple-ios-macabi.tar.xz": "0e1760828f4e0fa1cde0061ba5680619dcc1cdcafec9242cc18dc4547c73b1cd", + "rust-std-1.95.0-aarch64-apple-ios-sim.tar.xz": "4bfe5b0c74c10d121a8ac60f1833c7714b963f9130f6256ca313d94405267deb", + "rust-std-1.95.0-aarch64-apple-ios.tar.xz": "6fcc42d8dbba4910a128ffa32d62a730339a7e3882a90341a881f2edf66ff55a", + "rust-std-1.95.0-aarch64-linux-android.tar.xz": "de5e8fa5d955809891eea77682811fc90be705f78883bd94071e98f5a738d05b", + "rust-std-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "93d810b8872771afe04f66aa30a4eee48736aca693186e4c7cc2766e1a82e340", + "rust-std-1.95.0-aarch64-pc-windows-msvc.tar.xz": "be21b5a8a71c49b4dcbc19956233b0de7bfda3ee3c8a199148299f867e95cb42", + "rust-std-1.95.0-aarch64-unknown-fuchsia.tar.xz": "b311a0523f75e031d683d983515edb9baaf22a843dbd44ce2a30a3204752f592", + "rust-std-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "3a21b271b1ff973b94d69b25e7a39992f9fbcae1ab6d9475844a23e6ad3908ac", + "rust-std-1.95.0-aarch64-unknown-linux-musl.tar.xz": "f6710416ed9a7d5cf2a15efa761eb79a1deeb43f9961bbe05cc97bec4ef9064a", + "rust-std-1.95.0-aarch64-unknown-none-softfloat.tar.xz": "54d691468e25e7989b022a171337beadf78b5202877b312b75182b7f93efbb8b", + "rust-std-1.95.0-aarch64-unknown-none.tar.xz": "2b0c986dc9902866311f1fe2d44bc2bd84479d2ac84ed7ada76a5eb7ba37080f", + "rust-std-1.95.0-aarch64-unknown-uefi.tar.xz": "ca657564103024d345ca32e8e4ade7ebb395a51163d2897d9e5f8373c025e49e", + "rust-std-1.95.0-arm-linux-androideabi.tar.xz": "12a9c5fa24608159c2b2bd50abc0c6d0add407c0258cee894c2f61c07051a9c4", + "rust-std-1.95.0-arm-unknown-linux-gnueabi.tar.xz": "f6cd592dacdf41f724ee90a2f34db028e37ca2a7fb26fa86e93e8fd68e24d066", + "rust-std-1.95.0-arm-unknown-linux-gnueabihf.tar.xz": "fda8408ea17881c6529e27e58672d6c628f786cad557fac92856077e7a610239", + "rust-std-1.95.0-arm-unknown-linux-musleabi.tar.xz": "62f21fabc209fd0de53156764fc426da74c59525bb60cb4c1c3ffd1be0bbe00b", + "rust-std-1.95.0-arm-unknown-linux-musleabihf.tar.xz": "2842ce67f7a4c68c6e8b30ad3bb36484fb745edcc2694b2a36bf0609cf758044", + "rust-std-1.95.0-armv7-linux-androideabi.tar.xz": "61e95e144986c52ff9fa2fe3c249a68b2bf268adb2a2eeb81d80c180e43027f1", + "rust-std-1.95.0-armv7-unknown-linux-gnueabi.tar.xz": "e23454d6ca7fc3f5eb7cf9241572765176d86e9f45d4f394de31a5fd794e523f", + "rust-std-1.95.0-armv7-unknown-linux-gnueabihf.tar.xz": "bd319e18ca2dad0450f76277874d56356330da536be8cf271509f8e6f28ac6de", + "rust-std-1.95.0-armv7-unknown-linux-musleabi.tar.xz": "a49bc987e0531800f92825ce61402b5734ea747caa8970d3373506742ed7daa6", + "rust-std-1.95.0-armv7-unknown-linux-musleabihf.tar.xz": "77f9aaff4669c076edc96cdb99dc21749d2c692c862232b52176572c289fe671", + "rust-std-1.95.0-i686-linux-android.tar.xz": "e7b5e18d1d4119c7c1454ee8427933f27aeff9e81a22248c2829f5f299d3a937", + "rust-std-1.95.0-i686-pc-windows-gnu.tar.xz": "2e98fb94fc500690d7e72e071ce29dd98790ff518163963f5c82c32afba9231d", + "rust-std-1.95.0-i686-pc-windows-gnullvm.tar.xz": "b429a8c456d6a1815ece064102bd6a25f67d46db4f6d7b79dbe7d1b9d1f78e0f", + "rust-std-1.95.0-i686-pc-windows-msvc.tar.xz": "6206fd7e8bd119e9d1ba1c425ad25c282512eb0d5659f2dcb4be224b84715706", + "rust-std-1.95.0-i686-unknown-freebsd.tar.xz": "3b13b3ccecf482c0da3e94f19e0aa5e8e375622f4e09b30e6dae2b7638bee63f", + "rust-std-1.95.0-i686-unknown-linux-gnu.tar.xz": "527c5d5249a7f77b48d3c9da3ac512d27b47f43d08dbe3c6f82a3d5b35d8aa27", + "rust-std-1.95.0-i686-unknown-linux-musl.tar.xz": "af4d3e7aabb63d39a7a2ff5435cc993b65ff38a2d2e23f1967e519037a1b0455", + "rust-std-1.95.0-i686-unknown-uefi.tar.xz": "3233985273616ec36861f2d50b4a025c903b2bb8c45b171c0ae9e2de8342125b", + "rust-std-1.95.0-powerpc-unknown-linux-gnu.tar.xz": "59e0abbaa246502521e37c55b8d6cf88d5b8a697b0c70c61ec189937308f7246", + "rust-std-1.95.0-powerpc64-unknown-linux-gnu.tar.xz": "cc7fb9aa289ff1756502ae16a05e2885289165f01ed94a7c2db6576b3dae74a6", + "rust-std-1.95.0-powerpc64le-unknown-linux-gnu.tar.xz": "2370d9266051a0b23346d42e43a00f91b2daff22a963fb03e28ae50cb0b76c50", + "rust-std-1.95.0-powerpc64le-unknown-linux-musl.tar.xz": "0362ba4ecfe0bb508f0d2b064c6fbbfe72604d5ae1989d6a8a7b4fe5ff1889f1", + "rust-std-1.95.0-riscv32imc-unknown-none-elf.tar.xz": "04befebf3b15372dacb7e6c0fcdab842c17a63ea58bf2f4cdccd7182ae9195c6", + "rust-std-1.95.0-riscv64gc-unknown-linux-gnu.tar.xz": "50fe7869e166bb4c990a0e1664366b1ffdbe669664b7663cd03c079bd0efdcac", + "rust-std-1.95.0-riscv64gc-unknown-linux-musl.tar.xz": "e01bdbf5d6fa3e529671d49e87ba81dc9612101144f3ee5a0e1de3c48f27b47c", + "rust-std-1.95.0-riscv64gc-unknown-none-elf.tar.xz": "a4cb7a1527f3b56a39464e5ca2b174a27b708b26d78e604735eb5ffd9ee4d20b", + "rust-std-1.95.0-s390x-unknown-linux-gnu.tar.xz": "31978c1286afff9a0bb7f01c2ae4a39f40727b6100a82b6d934f146b06cde510", + "rust-std-1.95.0-thumbv6m-none-eabi.tar.xz": "602ec023c4615fc1c2d78b688554d42fa525e07e861c052f406fd7a607e5d5ee", + "rust-std-1.95.0-thumbv7em-none-eabi.tar.xz": "fb671966ba9aede333956ed43fcfe114ec890ca6e70369c9f3219871ee3ae8ae", + "rust-std-1.95.0-thumbv7em-none-eabihf.tar.xz": "fa3d189c09b64d818ad65a3fec1ce1c7d7b3908aea6fc4607a3fcc05067cad81", + "rust-std-1.95.0-thumbv7m-none-eabi.tar.xz": "e9e39f483ad4c1ce55fa4f508009e8d7d7ef25efe6592f7bfbabc159a24658ff", + "rust-std-1.95.0-thumbv8m.main-none-eabi.tar.xz": "3b64bffb193b37e83dc7b169add55f0ef3298220f8475468be4bb87276a68105", + "rust-std-1.95.0-thumbv8m.main-none-eabihf.tar.xz": "25ec92187d3a45fb1e397b0ba6000341d872523925a99a48b1978bdf1e6038cb", + "rust-std-1.95.0-wasm32-unknown-emscripten.tar.xz": "967b92a8682e8b8a1a459d776b36e41c906cdcac1008fef70e4800fec40d6864", + "rust-std-1.95.0-wasm32-unknown-unknown.tar.xz": "5587b89ff69623d09e476439d44a24453b4e4ea3d5e0b53a5c0a935151ff3fd1", + "rust-std-1.95.0-wasm32-wasip1-threads.tar.xz": "9079935a00a3c3aaf284957bbe82972983ce2708019687cec1f4988c30c1e0f3", + "rust-std-1.95.0-wasm32-wasip1.tar.xz": "86e5b6d98c7520bb9c3ad4f8cbbbf14beaf230b0f06b437db398e3c4f7dae43e", + "rust-std-1.95.0-wasm32-wasip2.tar.xz": "68146eb4c887431379966efa21d75dc957f18bd1166239c1deaa53fe38cb9ab4", + "rust-std-1.95.0-x86_64-apple-darwin.tar.xz": "2be13c14122b8d4d09b7f7c434fca9ae7215ec72049944189c88c4d9128ce504", + "rust-std-1.95.0-x86_64-apple-ios-macabi.tar.xz": "60b92e51e87f84046e0b19ccedc88c45b4b62c3ab10351f8473453f341c894f1", + "rust-std-1.95.0-x86_64-apple-ios.tar.xz": "11abb7b1c92b5a88b8c3ba21ee596a1be7dee5e817b336f26b4968d8ed5513ad", + "rust-std-1.95.0-x86_64-linux-android.tar.xz": "77b8e2be4a6e784a63cd77de944864c8044ddf4d5c7d56f663ada8a38a8319c4", + "rust-std-1.95.0-x86_64-pc-windows-gnu.tar.xz": "f57e045016a04130125fb43295d95f9ad2bebc296150eadb031dbf5167ad12bd", + "rust-std-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "3c52a0e34e0b4abe4439cabd77383408f5f9c80b6e249fbc0855df424b45008d", + "rust-std-1.95.0-x86_64-pc-windows-msvc.tar.xz": "7c659bdc88646e7e1befa370881bd311be87b26f006933a28b40dcab2f7cc832", + "rust-std-1.95.0-x86_64-unknown-freebsd.tar.xz": "dfe913c2f477172db10d3723d9f5c536d8b6f42776c979cc855820d8249adec3", + "rust-std-1.95.0-x86_64-unknown-fuchsia.tar.xz": "faa2bc09a3992f1d81b538121d64a3a396f4ec666e665c79d2ab47461c2d3206", + "rust-std-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "047ea7098803d3500fa1072e9cee5392697e21525559e4458128a2bf874aa382", + "rust-std-1.95.0-x86_64-unknown-linux-musl.tar.xz": "aee540abf132920f791ef781489851a078d69dff493fb628d49c1d573f92bb3a", + "rust-std-1.95.0-x86_64-unknown-netbsd.tar.xz": "7a82b71c53f20cb147a340819fcab645da220b312a96194531e631ee99783a7d", + "rust-std-1.95.0-x86_64-unknown-none.tar.xz": "7c151c0e7bf3b0b4d7136774cd3686e5f691b761b648b17e83af58e7669d3e01", + "rust-std-1.95.0-x86_64-unknown-uefi.tar.xz": "4cc55629480aa8ab5b39eb6b7458433b48461d6626fdea0330fb88e23af818ea", + "rustc-1.95.0-aarch64-apple-darwin.tar.xz": "149e85a285b6eba58eb6c8bdf7deb1b93763890598e62cb635a712e3a8454f04", + "rustc-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "758e729faabd8dabfab584d2bead59ca4fbcf1125ffc3c43a69332d6d9f2316b", + "rustc-1.95.0-aarch64-pc-windows-msvc.tar.xz": "0dbec9739b93427ccdd3948c3b1f83cec42e4c9545d930a8d1e1464ff4092c5f", + "rustc-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "0fe3689eeaed603e5ef24572d11597d3edadaefd2cb181674ad621260f2501d2", + "rustc-1.95.0-x86_64-apple-darwin.tar.xz": "33db457715446a69ed6f69f78f5fbb9ca8e17a16585d1d7a0060479bfe4c7afc", + "rustc-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "44ffbe057bb8f967087a1ad549a7139e9e5017d3aab396f42a6393f897e39531", + "rustc-1.95.0-x86_64-pc-windows-msvc.tar.xz": "4cb1f3b578adc6541cbe13a6f85f1fd8c0ce643d90b506a36dee24c680864c67", + "rustc-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "8426a3d170a5879f5682f5fbdd024a1779b3951e7baba685af2d6dc32a6dfc15", + "rustfmt-1.95.0-aarch64-apple-darwin.tar.xz": "c54af79adfdc790d27fc56e24407e370c80be5a89ad537eef9fbd45d3c3e28e8", + "rustfmt-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "85b690b664ee4f03521c6bc3f1af8fd7e6efae5851a8a7ff18965594484688e2", + "rustfmt-1.95.0-aarch64-pc-windows-msvc.tar.xz": "a873c048743e6da29e09a8b55c774ee113f8f6ae4fd57d988d304a6453801b34", + "rustfmt-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "64cce868f0f3d29f1524e11e9bab01ac9d538a31665fea1cd6b78af46a1c0a41", + "rustfmt-1.95.0-x86_64-apple-darwin.tar.xz": "5f7228f40a160e80d260e74e068d6fec8627aa02f1f5ae29d2019b9347076401", + "rustfmt-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "74eb0bb0227af0df81a5df9cbf8384e5e038c4cd575152ecc27dfa891ff3756a", + "rustfmt-1.95.0-x86_64-pc-windows-msvc.tar.xz": "8bcf91606e36b8a0164efafde50709cd7a3c02143a2edaa81dbf3dccd6ed8f4c", + "rustfmt-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "f1b2a7301513ffdd95ebf22ebbdd932e4d17fc806f748d93924740d0297b1396" } } } diff --git a/codex-rs/.github/workflows/cargo-audit.yml b/codex-rs/.github/workflows/cargo-audit.yml index 0c41471b65..1d935d61e8 100644 --- a/codex-rs/.github/workflows/cargo-audit.yml +++ b/codex-rs/.github/workflows/cargo-audit.yml @@ -17,7 +17,7 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - name: Install cargo-audit uses: taiki-e/install-action@v2 with: diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 044146d9de..1951db642f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -181,7 +181,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -193,7 +193,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -207,7 +207,7 @@ dependencies = [ "bech32", "chacha20poly1305", "cookie-factory", - "hmac", + "hmac 0.12.1", "i18n-embed", "i18n-embed-fl", "lazy_static", @@ -216,7 +216,7 @@ dependencies = [ "rand 0.8.5", "rust-embed", "scrypt", - "sha2", + "sha2 0.10.9", "subtle", "x25519-dalek", "zeroize", @@ -231,12 +231,12 @@ dependencies = [ "base64 0.21.7", "chacha20poly1305", "cookie-factory", - "hkdf", + "hkdf 0.12.4", "io_tee", "nom 7.1.3", "rand 0.8.5", "secrecy", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -776,7 +776,7 @@ dependencies = [ "p256", "rand 0.8.5", "ring", - "sha2", + "sha2 0.10.9", "time", "tokio", "tracing", @@ -946,11 +946,11 @@ dependencies = [ "bytes", "form_urlencoded", "hex", - "hmac", + "hmac 0.12.1", "http 0.2.12", "http 1.4.0", "percent-encoding", - "sha2", + "sha2 0.10.9", "time", "tracing", ] @@ -1147,7 +1147,6 @@ dependencies = [ "axum-core", "base64 0.22.1", "bytes", - "form_urlencoded", "futures-util", "http 1.4.0", "http-body 1.0.1", @@ -1163,15 +1162,13 @@ dependencies = [ "serde_core", "serde_json", "serde_path_to_error", - "serde_urlencoded", - "sha1", + "sha1 0.10.6", "sync_wrapper", "tokio", "tokio-tungstenite", "tower", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -1190,7 +1187,6 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -1328,7 +1324,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1340,6 +1336,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -1613,7 +1618,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -1623,7 +1639,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -1666,7 +1682,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] @@ -1759,6 +1775,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "cmp_any" version = "0.8.1" @@ -1804,10 +1826,10 @@ dependencies = [ "jsonwebtoken", "pretty_assertions", "rand 0.9.3", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1825,7 +1847,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", - "sha1", + "sha1 0.10.6", "tokio", "tracing", ] @@ -1858,7 +1880,7 @@ dependencies = [ "http 1.4.0", "pretty_assertions", "regex-lite", - "reqwest", + "reqwest 0.12.28", "schemars 0.8.22", "serde", "serde_json", @@ -1906,6 +1928,7 @@ dependencies = [ "codex-git-utils", "codex-guardian", "codex-hooks", + "codex-image-generation-extension", "codex-login", "codex-mcp", "codex-memories-extension", @@ -1929,19 +1952,20 @@ dependencies = [ "codex-utils-json-to-toml", "codex-utils-pty", "codex-web-search-extension", + "codex-windows-sandbox", "core_test_support", "flate2", "futures", - "hmac", + "hmac 0.12.1", "opentelemetry", "opentelemetry_sdk", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "rmcp", "serde", "serde_json", "serial_test", - "sha2", + "sha2 0.10.9", "shlex", "tar", "tempfile", @@ -1999,10 +2023,10 @@ dependencies = [ "futures", "libc", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "tokio", "tokio-tungstenite", @@ -2078,13 +2102,13 @@ dependencies = [ "constant_time_eq 0.3.1", "futures", "gethostname", - "hmac", + "hmac 0.12.1", "jsonwebtoken", "owo-colors", "pretty_assertions", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "time", "tokio", @@ -2168,7 +2192,7 @@ dependencies = [ "codex-model-provider", "codex-protocol", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", ] @@ -2283,6 +2307,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "unicode-segmentation", + "url", "which 8.0.0", "windows-sys 0.52.0", ] @@ -2303,7 +2328,7 @@ dependencies = [ "pretty_assertions", "rand 0.9.3", "rcgen", - "reqwest", + "reqwest 0.12.28", "rustls", "rustls-native-certs", "rustls-pki-types", @@ -2332,11 +2357,11 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-utils-absolute-path", - "hmac", + "hmac 0.12.1", "pretty_assertions", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "tokio", @@ -2365,7 +2390,7 @@ dependencies = [ "owo-colors", "pretty_assertions", "ratatui", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "supports-color 3.0.2", @@ -2454,7 +2479,7 @@ dependencies = [ "serde_ignored", "serde_json", "serde_path_to_error", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "tokio", @@ -2479,7 +2504,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", - "sha1", + "sha1 0.10.6", "tempfile", "tokio", "tracing", @@ -2575,12 +2600,12 @@ dependencies = [ "pretty_assertions", "rand 0.9.3", "regex-lite", - "reqwest", + "reqwest 0.12.28", "rmcp", "serde", "serde_json", "serial_test", - "sha1", + "sha1 0.10.6", "shlex", "similar", "tempfile", @@ -2600,7 +2625,7 @@ dependencies = [ "uuid", "walkdir", "which 8.0.0", - "whoami", + "whoami 1.6.1", "wiremock", "zstd 0.13.3", ] @@ -2648,7 +2673,7 @@ dependencies = [ "flate2", "libc", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "semver", "serde", "serde_json", @@ -2777,7 +2802,7 @@ dependencies = [ "http 1.4.0", "pretty_assertions", "prost 0.14.3", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serial_test", @@ -2870,7 +2895,7 @@ dependencies = [ "codex-utils-output-truncation", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", ] @@ -3015,6 +3040,31 @@ dependencies = [ "uuid", ] +[[package]] +name = "codex-image-generation-extension" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "codex-api", + "codex-core", + "codex-extension-api", + "codex-features", + "codex-login", + "codex-model-provider", + "codex-model-provider-info", + "codex-protocol", + "codex-tools", + "http 1.4.0", + "pretty_assertions", + "schemars 0.8.22", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "codex-install-context" version = "0.0.0" @@ -3051,7 +3101,7 @@ dependencies = [ "seccompiler", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "tokio", "url", @@ -3063,7 +3113,7 @@ version = "0.0.0" dependencies = [ "codex-core", "codex-model-provider-info", - "reqwest", + "reqwest 0.12.28", "serde_json", "tokio", "tracing", @@ -3097,11 +3147,11 @@ dependencies = [ "pretty_assertions", "rand 0.9.3", "regex-lite", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serial_test", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "tiny_http", @@ -3136,7 +3186,7 @@ dependencies = [ "rmcp", "serde", "serde_json", - "sha1", + "sha1 0.10.6", "tempfile", "thiserror 2.0.18", "tokio", @@ -3361,7 +3411,7 @@ dependencies = [ "codex-model-provider-info", "futures", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "semver", "serde_json", "tokio", @@ -3389,7 +3439,7 @@ dependencies = [ "opentelemetry_sdk", "os_info", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "strum_macros 0.28.0", @@ -3441,7 +3491,7 @@ dependencies = [ "landlock", "pretty_assertions", "quick-xml", - "reqwest", + "reqwest 0.12.28", "schemars 0.8.22", "seccompiler", "serde", @@ -3489,7 +3539,7 @@ dependencies = [ "ctor 0.6.3", "libc", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tiny_http", @@ -3517,12 +3567,12 @@ dependencies = [ "keyring", "oauth2", "pretty_assertions", - "reqwest", + "reqwest 0.13.4", "rmcp", "serde", "serde_json", "serial_test", - "sha2", + "sha2 0.10.9", "sse-stream", "tempfile", "thiserror 2.0.18", @@ -3613,7 +3663,7 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "tracing", ] @@ -3851,12 +3901,12 @@ dependencies = [ "ratatui", "ratatui-macros", "regex-lite", - "reqwest", + "reqwest 0.12.28", "rmcp", "serde", "serde_json", "serial_test", - "sha2", + "sha2 0.10.9", "shlex", "strum 0.27.2", "strum_macros 0.28.0", @@ -3924,7 +3974,7 @@ name = "codex-utils-cache" version = "0.0.0" dependencies = [ "lru 0.16.3", - "sha1", + "sha1 0.10.6", "tokio", ] @@ -4261,7 +4311,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "proptest", "serde_core", ] @@ -4422,7 +4472,7 @@ dependencies = [ "opentelemetry_sdk", "pretty_assertions", "regex-lite", - "reqwest", + "reqwest 0.12.28", "serde_json", "shlex", "similar", @@ -4467,7 +4517,7 @@ dependencies = [ "core-foundation-sys", "coreaudio-rs", "dasp_sample", - "jni", + "jni 0.21.1", "js-sys", "libc", "mach2", @@ -4489,6 +4539,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -4616,6 +4675,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "crypto_box" version = "0.9.1" @@ -4693,6 +4761,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -4700,9 +4777,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -4934,10 +5011,10 @@ dependencies = [ "cbc", "dbus", "fastrand", - "hkdf", + "hkdf 0.12.4", "num", "once_cell", - "sha2", + "sha2 0.10.9", "zeroize", ] @@ -5127,12 +5204,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "diplomat" version = "0.15.0" @@ -5350,7 +5438,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -5376,7 +5464,7 @@ dependencies = [ "curve25519-dalek", "ed25519", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -5398,7 +5486,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -5547,13 +5635,12 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] name = "etcetera" -version = "0.8.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "home", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -5809,17 +5896,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] - [[package]] name = "flume" version = "0.12.0" @@ -5926,9 +6002,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -5936,15 +6012,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -5964,9 +6040,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -5983,9 +6059,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -5994,21 +6070,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -6018,7 +6094,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -6098,11 +6173,25 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + [[package]] name = "gif" version = "0.14.1" @@ -7160,11 +7249,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -7179,7 +7268,7 @@ dependencies = [ "http 1.4.0", "httpdate", "mime", - "sha1", + "sha1 0.10.6", ] [[package]] @@ -7277,7 +7366,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -7286,7 +7384,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -7382,6 +7489,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -7420,7 +7536,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.5", + "webpki-roots", ] [[package]] @@ -7737,6 +7853,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -8139,19 +8261,68 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.0", "log", "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.114", +] + [[package]] name = "jni-sys" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.114", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -8293,9 +8464,12 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" @@ -8341,9 +8515,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", @@ -8357,7 +8531,7 @@ source = "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df dependencies = [ "cxx", "glib", - "jni", + "jni 0.21.1", "js-sys", "lazy_static", "livekit-protocol", @@ -8647,12 +8821,12 @@ dependencies = [ [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] @@ -8798,7 +8972,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" dependencies = [ "bitflags 2.10.0", - "jni-sys", + "jni-sys 0.3.0", "log", "ndk-sys", "num_enum", @@ -8817,7 +8991,7 @@ version = "0.5.0+25.2.9519653" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" dependencies = [ - "jni-sys", + "jni-sys 0.3.0", ] [[package]] @@ -8982,22 +9156,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.5", - "smallvec", - "zeroize", -] - [[package]] name = "num-complex" version = "0.4.6" @@ -9062,7 +9220,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -9117,11 +9274,11 @@ dependencies = [ "getrandom 0.2.17", "http 1.4.0", "rand 0.8.5", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serde_path_to_error", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "url", ] @@ -9312,7 +9469,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" dependencies = [ - "jni", + "jni 0.21.1", "ndk", "ndk-context", "num-derive", @@ -9478,7 +9635,7 @@ dependencies = [ "bytes", "http 1.4.0", "opentelemetry", - "reqwest", + "reqwest 0.12.28", ] [[package]] @@ -9493,7 +9650,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost 0.14.3", - "reqwest", + "reqwest 0.12.28", "serde_json", "thiserror 2.0.18", "tokio", @@ -9608,7 +9765,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -9730,10 +9887,10 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ - "digest", - "hmac", + "digest 0.10.7", + "hmac 0.12.1", "password-hash", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -9742,8 +9899,8 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", - "hmac", + "digest 0.10.7", + "hmac 0.12.1", ] [[package]] @@ -9844,17 +10001,6 @@ dependencies = [ "futures-io", ] -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - [[package]] name = "pkcs8" version = "0.10.2" @@ -9917,7 +10063,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -10154,7 +10300,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes", - "heck 0.5.0", + "heck 0.4.1", "itertools 0.11.0", "log", "multimap", @@ -10174,7 +10320,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "itertools 0.14.0", "log", "multimap", @@ -10326,6 +10472,7 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -10370,6 +10517,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radix_trie" version = "0.2.1" @@ -10533,7 +10686,7 @@ dependencies = [ "rama-utils", "rand 0.9.3", "serde", - "sha1", + "sha1 0.10.6", ] [[package]] @@ -10586,7 +10739,7 @@ checksum = "b28ee9e1e5d39264414b71f5c33e7fbb66b382c3fac456fe0daad39cf5509933" dependencies = [ "ahash", "const_format", - "flume 0.12.0", + "flume", "hex", "ipnet", "itertools 0.14.0", @@ -10601,7 +10754,7 @@ dependencies = [ "rama-macros", "rama-utils", "serde", - "sha2", + "sha2 0.10.9", "socket2 0.6.2", "tokio", ] @@ -10654,7 +10807,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "webpki-roots 1.0.5", + "webpki-roots", "x509-parser", ] @@ -10721,6 +10874,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20 0.10.0", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -10759,6 +10923,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -10978,9 +11148,49 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", "web-sys", - "webpki-roots 1.0.5", ] [[package]] @@ -11005,7 +11215,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -11025,12 +11235,11 @@ dependencies = [ [[package]] name = "rmcp" -version = "0.15.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee" +checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" dependencies = [ "async-trait", - "axum", "base64 0.22.1", "bytes", "chrono", @@ -11042,8 +11251,8 @@ dependencies = [ "pastey", "pin-project-lite", "process-wrap", - "rand 0.9.3", - "reqwest", + "rand 0.10.1", + "reqwest 0.13.4", "rmcp-macros", "schemars 1.2.1", "serde", @@ -11061,9 +11270,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.15.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e88ad84b8b6237a934534a62b379a5be6388915663c0cc598ceb9b3292bbbfe" +checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -11072,26 +11281,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rtrb" version = "0.3.3" @@ -11133,7 +11322,7 @@ version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ - "sha2", + "sha2 0.10.9", "walkdir", ] @@ -11247,6 +11436,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.5.1", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -11459,7 +11675,7 @@ checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ "pbkdf2 0.12.2", "salsa20", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -11510,12 +11726,12 @@ dependencies = [ "cbc", "futures-util", "generic-array", - "hkdf", + "hkdf 0.12.4", "num", "once_cell", "rand 0.8.5", "serde", - "sha2", + "sha2 0.10.9", "zbus", ] @@ -11584,7 +11800,7 @@ checksum = "2f925d575b468e88b079faf590a8dd0c9c99e2ec29e9bab663ceb8b45056312f" dependencies = [ "httpdate", "native-tls", - "reqwest", + "reqwest 0.12.28", "sentry-actix", "sentry-backtrace", "sentry-contexts", @@ -11906,8 +12122,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -11916,8 +12143,8 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" dependencies = [ - "digest", - "sha1", + "digest 0.10.7", + "sha1 0.10.6", ] [[package]] @@ -11933,8 +12160,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -12005,7 +12243,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -12015,6 +12253,16 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -12117,9 +12365,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" dependencies = [ "sqlx-core", "sqlx-macros", @@ -12130,12 +12378,13 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ "base64 0.22.1", "bytes", + "cfg-if", "chrono", "crc", "crossbeam-queue", @@ -12145,17 +12394,16 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "hashlink", "indexmap 2.13.0", "log", "memchr", - "once_cell", "percent-encoding", "rustls", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror 2.0.18", "time", @@ -12164,14 +12412,14 @@ dependencies = [ "tracing", "url", "uuid", - "webpki-roots 0.26.11", + "webpki-roots", ] [[package]] name = "sqlx-macros" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" dependencies = [ "proc-macro2", "quote", @@ -12182,79 +12430,64 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" dependencies = [ + "cfg-if", "dotenvy", "either", "heck 0.5.0", "hex", - "once_cell", "proc-macro2", "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", "syn 2.0.114", + "thiserror 2.0.18", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "atoi", - "base64 0.22.1", "bitflags 2.10.0", "byteorder", "bytes", "chrono", "crc", - "digest", + "digest 0.11.3", "dotenvy", "either", - "futures-channel", "futures-core", - "futures-io", "futures-util", "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", "log", - "md-5", - "memchr", - "once_cell", "percent-encoding", - "rand 0.8.5", - "rsa", "serde", - "sha1", - "sha2", - "smallvec", + "sha1 0.11.0", + "sha2 0.11.0", "sqlx-core", - "stringprep", "thiserror 2.0.18", "time", "tracing", "uuid", - "whoami", ] [[package]] name = "sqlx-postgres" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64 0.22.1", @@ -12268,18 +12501,16 @@ dependencies = [ "futures-core", "futures-util", "hex", - "hkdf", - "hmac", - "home", + "hkdf 0.13.0", + "hmac 0.13.0", "itoa", "log", "md-5", "memchr", - "once_cell", - "rand 0.8.5", + "rand 0.10.1", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "smallvec", "sqlx-core", "stringprep", @@ -12287,18 +12518,19 @@ dependencies = [ "time", "tracing", "uuid", - "whoami", + "whoami 2.1.2", ] [[package]] name = "sqlx-sqlite" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", "chrono", - "flume 0.11.1", + "flume", + "form_urlencoded", "futures-channel", "futures-core", "futures-executor", @@ -12308,7 +12540,6 @@ dependencies = [ "log", "percent-encoding", "serde", - "serde_urlencoded", "sqlx-core", "thiserror 2.0.18", "time", @@ -12706,7 +12937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -13539,7 +13770,7 @@ dependencies = [ "rand 0.9.3", "rustls", "rustls-pki-types", - "sha1", + "sha1 0.10.6", "thiserror 2.0.18", "utf-8", ] @@ -13566,9 +13797,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "uds_windows" @@ -13701,7 +13932,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -13913,6 +14144,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -13978,6 +14218,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -13991,6 +14253,31 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + [[package]] name = "wayland-backend" version = "0.3.12" @@ -14088,7 +14375,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97" dependencies = [ "core-foundation 0.10.1", - "jni", + "jni 0.21.1", "log", "ndk-context", "objc2", @@ -14106,15 +14393,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.5", -] - [[package]] name = "webpki-roots" version = "1.0.5" @@ -14146,7 +14424,7 @@ dependencies = [ "anyhow", "fs2", "regex", - "reqwest", + "reqwest 0.12.28", "scratch", "semver", "zip 0.6.6", @@ -14192,6 +14470,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" + [[package]] name = "widestring" version = "1.2.1" @@ -14823,6 +15107,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "wl-clipboard-rs" @@ -14995,7 +15361,7 @@ dependencies = [ "rand 0.8.5", "serde", "serde_repr", - "sha1", + "sha1 0.10.6", "static_assertions", "tracing", "uds_windows", @@ -15139,9 +15505,9 @@ dependencies = [ "crc32fast", "crossbeam-utils", "flate2", - "hmac", + "hmac 0.12.1", "pbkdf2 0.11.0", - "sha1", + "sha1 0.10.6", "time", "zstd 0.11.2+zstd.1.5.2", ] @@ -15162,12 +15528,12 @@ dependencies = [ "displaydoc", "flate2", "getrandom 0.3.4", - "hmac", + "hmac 0.12.1", "indexmap 2.13.0", "lzma-rs", "memchr", "pbkdf2 0.12.2", - "sha1", + "sha1 0.10.6", "thiserror 2.0.18", "time", "xz2", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 6c9737e4ad..808cdef1b9 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -47,6 +47,7 @@ members = [ "ext/extension-api", "ext/goal", "ext/guardian", + "ext/image-generation", "ext/memories", "ext/web-search", "external-agent-migration", @@ -165,6 +166,7 @@ codex-execpolicy = { path = "execpolicy" } codex-extension-api = { path = "ext/extension-api" } codex-goal-extension = { path = "ext/goal" } codex-guardian = { path = "ext/guardian" } +codex-image-generation-extension = { path = "ext/image-generation" } codex-external-agent-migration = { path = "external-agent-migration" } codex-external-agent-sessions = { path = "external-agent-sessions" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } @@ -342,7 +344,7 @@ rcgen = { version = "0.14.7", default-features = false, features = [ regex = "1.12.3" regex-lite = "0.1.8" reqwest = { version = "0.12", features = ["cookies"] } -rmcp = { version = "0.15.0", default-features = false } +rmcp = { version = "1.7.0", default-features = false } runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" } rustls = { version = "0.23", default-features = false, features = [ "ring", @@ -366,13 +368,14 @@ sha2 = "0.10" shlex = "1.3.0" similar = "2.7.0" socket2 = "0.6.1" -sqlx = { version = "0.8.6", default-features = false, features = [ +sqlx = { version = "0.9.0", default-features = false, features = [ "chrono", "json", "macros", "migrate", - "runtime-tokio-rustls", - "sqlite", + "runtime-tokio", + "tls-rustls", + "sqlite-bundled", "time", "uuid", ] } diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 26daadf5f5..6a47ec78ca 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -263,6 +263,7 @@ fn sample_thread_resume_response_with_source( sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + initial_turns_page: None, }) } @@ -271,6 +272,7 @@ fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest request_id: RequestId::Integer(request_id), params: TurnStartParams { thread_id: thread_id.to_string(), + client_user_message_id: None, input: vec![ UserInput::Text { text: "hello".to_string(), @@ -390,6 +392,7 @@ fn sample_turn_steer_request( params: TurnSteerParams { thread_id: thread_id.to_string(), expected_turn_id: expected_turn_id.to_string(), + client_user_message_id: None, input: vec![ UserInput::Text { text: "more".to_string(), @@ -1213,6 +1216,7 @@ fn compaction_event_serializes_expected_shape() { completed_at: 106, duration_ms: Some(6543), }, + "session-thread-1".to_string(), sample_app_server_client_metadata(), sample_runtime_metadata(), Some(ThreadSource::User), @@ -1229,6 +1233,7 @@ fn compaction_event_serializes_expected_shape() { "event_type": "codex_compaction_event", "event_params": { "thread_id": "thread-1", + "session_id": "session-thread-1", "turn_id": "turn-1", "app_server_client": { "product_client_id": DEFAULT_ORIGINATOR, @@ -1307,6 +1312,7 @@ fn thread_initialized_event_serializes_expected_shape() { event_type: "codex_thread_initialized", event_params: ThreadInitializedEventParams { thread_id: "thread-0".to_string(), + session_id: "session-thread-0".to_string(), app_server_client: CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), client_name: Some("codex-tui".to_string()), @@ -1338,6 +1344,7 @@ fn thread_initialized_event_serializes_expected_shape() { "event_type": "codex_thread_initialized", "event_params": { "thread_id": "thread-0", + "session_id": "session-thread-0", "app_server_client": { "product_client_id": DEFAULT_ORIGINATOR, "client_name": "codex-tui", @@ -1605,6 +1612,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize let payload = serde_json::to_value(&events).expect("serialize events"); assert_eq!(payload.as_array().expect("events array").len(), 1); assert_eq!(payload[0]["event_type"], "codex_thread_initialized"); + assert_eq!(payload[0]["event_params"]["session_id"], "session-thread-1"); assert_eq!( payload[0]["event_params"]["app_server_client"]["product_client_id"], DEFAULT_ORIGINATOR @@ -1781,6 +1789,7 @@ async fn compaction_event_ingests_custom_fact() { let payload = serde_json::to_value(&events).expect("serialize events"); assert_eq!(payload.as_array().expect("events array").len(), 1); assert_eq!(payload[0]["event_type"], "codex_compaction_event"); + assert_eq!(payload[0]["event_params"]["session_id"], "session-thread-1"); assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1"); assert_eq!(payload[0]["event_params"]["turn_id"], "turn-compact"); assert_eq!( @@ -1905,6 +1914,10 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { let payload = serde_json::to_value(&events).expect("serialize events"); assert_eq!(payload.as_array().expect("events array").len(), 1); assert_eq!(payload[0]["event_type"], "codex_guardian_review"); + assert_eq!( + payload[0]["event_params"]["session_id"], + "session-thread-guardian" + ); assert_eq!(payload[0]["event_params"]["thread_id"], "thread-guardian"); assert_eq!(payload[0]["event_params"]["turn_id"], "turn-guardian"); assert_eq!(payload[0]["event_params"]["review_id"], "review-guardian"); @@ -2397,6 +2410,7 @@ async fn item_review_summaries_do_not_cross_threads_with_reused_item_ids() { fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: None, product_client_id: "codex-tui".to_string(), @@ -2440,6 +2454,7 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() { .expect("valid thread id"); let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-spawn".to_string(), parent_thread_id: None, product_client_id: "codex-tui".to_string(), @@ -2459,18 +2474,21 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() { )); let payload = serde_json::to_value(&event).expect("serialize thread spawn subagent event"); + assert_eq!(payload["event_params"]["thread_id"], "thread-spawn"); assert_eq!(payload["event_params"]["thread_source"], "subagent"); assert_eq!(payload["event_params"]["subagent_source"], "thread_spawn"); assert_eq!( payload["event_params"]["parent_thread_id"], "11111111-1111-1111-1111-111111111111" ); + assert_eq!(payload["event_params"]["session_id"], "session-root"); } #[test] fn subagent_thread_started_memory_consolidation_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-memory".to_string(), parent_thread_id: None, product_client_id: "codex-tui".to_string(), @@ -2496,6 +2514,7 @@ fn subagent_thread_started_memory_consolidation_serializes_expected_shape() { fn subagent_thread_started_other_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-guardian".to_string(), parent_thread_id: None, product_client_id: "codex-tui".to_string(), @@ -2517,6 +2536,7 @@ fn subagent_thread_started_other_serializes_expected_shape() { fn subagent_thread_started_other_serializes_explicit_parent_thread_id() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-guardian".to_string(), parent_thread_id: Some("parent-thread-guardian".to_string()), product_client_id: "codex-tui".to_string(), @@ -2546,6 +2566,7 @@ async fn subagent_thread_started_publishes_without_initialize() { .ingest( AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: None, product_client_id: "codex-tui".to_string(), @@ -2619,6 +2640,7 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() { .ingest( AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: None, product_client_id: "parent-client".to_string(), @@ -2666,6 +2688,8 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() { .await; let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload[0]["event_params"]["session_id"], "session-root"); + assert_eq!(payload[0]["event_params"]["thread_id"], "thread-review"); assert_eq!( payload[0]["event_params"]["app_server_client"]["product_client_id"], "parent-client" @@ -2686,6 +2710,7 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() { .ingest( AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-subagent".to_string(), parent_thread_id: Some("thread-1".to_string()), product_client_id: "codex-tui".to_string(), @@ -3191,6 +3216,7 @@ fn turn_event_serializes_expected_shape() { event_type: "codex_turn_event", event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), + session_id: "session-thread-2".to_string(), turn_id: "turn-2".to_string(), app_server_client: sample_app_server_client_metadata(), runtime: sample_runtime_metadata(), @@ -3241,6 +3267,7 @@ fn turn_event_serializes_expected_shape() { "event_type": "codex_turn_event", "event_params": { "thread_id": "thread-2", + "session_id": "session-thread-2", "turn_id": "turn-2", "submission_type": null, "app_server_client": { @@ -3342,6 +3369,10 @@ async fn accepted_turn_steer_emits_expected_event() { let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!( + payload["event_params"]["session_id"], + json!("session-thread-2") + ); assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); @@ -3559,6 +3590,10 @@ async fn turn_lifecycle_emits_turn_event() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_type"], json!("codex_turn_event")); assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!( + payload["event_params"]["session_id"], + json!("session-thread-2") + ); assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( payload["event_params"]["app_server_client"], diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index c6a928afa3..4a375b0639 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -89,6 +89,7 @@ fn sample_turn_start_request() -> ClientRequest { request_id: RequestId::Integer(1), params: TurnStartParams { thread_id: "thread-1".to_string(), + client_user_message_id: None, input: Vec::new(), ..Default::default() }, @@ -101,6 +102,7 @@ fn sample_turn_steer_request() -> ClientRequest { params: TurnSteerParams { thread_id: "thread-1".to_string(), expected_turn_id: "turn-1".to_string(), + client_user_message_id: None, input: Vec::new(), responsesapi_client_metadata: None, additional_context: None, @@ -172,6 +174,7 @@ fn sample_thread_resume_response() -> ClientResponsePayload { sandbox: AppServerSandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, + initial_turns_page: None, }) } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 87cb165ac2..8791e5767a 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -147,6 +147,7 @@ pub(crate) struct CodexRuntimeMetadata { #[derive(Serialize)] pub(crate) struct ThreadInitializedEventParams { pub(crate) thread_id: String, + pub(crate) session_id: String, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, pub(crate) model: String, @@ -420,6 +421,7 @@ impl GuardianReviewAnalyticsResult { #[derive(Serialize)] pub(crate) struct GuardianReviewEventPayload { + pub(crate) session_id: String, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, #[serde(flatten)] @@ -738,6 +740,7 @@ pub(crate) struct CodexHookRunEventRequest { #[derive(Serialize)] pub(crate) struct CodexCompactionEventParams { pub(crate) thread_id: String, + pub(crate) session_id: String, pub(crate) turn_id: String, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, @@ -767,6 +770,7 @@ pub(crate) struct CodexCompactionEventRequest { #[derive(Serialize)] pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, + pub(crate) session_id: String, pub(crate) turn_id: String, // TODO(rhan-oai): Populate once queued/default submission type is plumbed from // the turn/start callsites instead of always being reported as None. @@ -821,6 +825,7 @@ pub(crate) struct CodexTurnEventRequest { #[derive(Serialize)] pub(crate) struct CodexTurnSteerEventParams { pub(crate) thread_id: String, + pub(crate) session_id: String, pub(crate) expected_turn_id: Option, pub(crate) accepted_turn_id: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, @@ -926,6 +931,7 @@ pub(crate) fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPlu pub(crate) fn codex_compaction_event_params( input: CodexCompactionEvent, + session_id: String, app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, thread_source: Option, @@ -934,6 +940,7 @@ pub(crate) fn codex_compaction_event_params( ) -> CodexCompactionEventParams { CodexCompactionEventParams { thread_id: input.thread_id, + session_id, turn_id: input.turn_id, app_server_client, runtime, @@ -1026,6 +1033,7 @@ pub(crate) fn subagent_thread_started_event_request( ) -> ThreadInitializedEvent { let event_params = ThreadInitializedEventParams { thread_id: input.thread_id, + session_id: input.session_id, app_server_client: CodexAppServerClientMetadata { product_client_id: input.product_client_id, client_name: Some(input.client_name), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 56bd0a5d2c..9a85276712 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -199,6 +199,7 @@ pub struct AppInvocation { #[derive(Clone)] pub struct SubAgentThreadStartedInput { + pub session_id: String, pub thread_id: String, pub parent_thread_id: Option, pub product_client_id: String, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d072720d10..f2f036b8f4 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -255,6 +255,7 @@ struct ItemReviewSummary { #[derive(Clone)] struct ThreadMetadataState { + session_id: String, thread_source: Option, initialization_mode: ThreadInitializationMode, subagent_source: Option, @@ -263,6 +264,7 @@ struct ThreadMetadataState { impl ThreadMetadataState { fn from_thread_metadata( + session_id: String, session_source: &SessionSource, thread_source: Option, initialization_mode: ThreadInitializationMode, @@ -281,6 +283,7 @@ impl ThreadMetadataState { | SessionSource::Unknown => (None, None), }; Self { + session_id, thread_source, initialization_mode, subagent_source, @@ -525,6 +528,7 @@ impl AnalyticsReducer { thread_state .metadata .get_or_insert_with(|| ThreadMetadataState { + session_id: input.session_id.clone(), thread_source: Some(ThreadSource::Subagent), initialization_mode: ThreadInitializationMode::New, subagent_source: Some(subagent_source_name(&input.subagent_source)), @@ -543,8 +547,8 @@ impl AnalyticsReducer { input: GuardianReviewEventParams, out: &mut Vec, ) { - let Some(connection_state) = - self.thread_connection_or_warn(AnalyticsDropSite::guardian(&input)) + let Some((connection_state, thread_metadata)) = + self.thread_context_or_warn(AnalyticsDropSite::guardian(&input)) else { return; }; @@ -552,6 +556,7 @@ impl AnalyticsReducer { GuardianReviewEventRequest { event_type: "codex_guardian_review", event_params: GuardianReviewEventPayload { + session_id: thread_metadata.session_id.clone(), app_server_client: connection_state.app_server_client.clone(), runtime: connection_state.runtime.clone(), guardian_review: input, @@ -1231,11 +1236,13 @@ impl AnalyticsReducer { out: &mut Vec, ) { let session_source: SessionSource = thread.source.into(); + let session_id = thread.session_id; let thread_id = thread.id; let Some(connection_state) = self.connections.get(&connection_id) else { return; }; let thread_metadata = ThreadMetadataState::from_thread_metadata( + session_id.clone(), &session_source, thread.thread_source.map(Into::into), initialization_mode, @@ -1252,6 +1259,7 @@ impl AnalyticsReducer { event_type: "codex_thread_initialized", event_params: ThreadInitializedEventParams { thread_id, + session_id, app_server_client: connection_state.app_server_client.clone(), runtime: connection_state.runtime.clone(), model, @@ -1277,6 +1285,7 @@ impl AnalyticsReducer { event_type: "codex_compaction_event", event_params: codex_compaction_event_params( input, + thread_metadata.session_id.clone(), connection_state.app_server_client.clone(), connection_state.runtime.clone(), thread_metadata.thread_source, @@ -1379,6 +1388,7 @@ impl AnalyticsReducer { event_type: "codex_turn_steer_event", event_params: CodexTurnSteerEventParams { thread_id: pending_request.thread_id, + session_id: thread_metadata.session_id.clone(), expected_turn_id: Some(pending_request.expected_turn_id), accepted_turn_id, app_server_client: connection_state.app_server_client.clone(), @@ -2447,6 +2457,7 @@ fn codex_turn_event_params( let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, + session_id: thread_metadata.session_id.clone(), turn_id, app_server_client, runtime, diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 552defad1b..fb51ab25a2 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1085,6 +1085,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -3541,6 +3543,42 @@ } ] }, + "ThreadResumeInitialTurnsPageParams": { + "properties": { + "itemsView": { + "anyOf": [ + { + "$ref": "#/definitions/TurnItemsView" + }, + { + "type": "null" + } + ], + "description": "How much item detail to include for each returned turn; defaults to summary." + }, + "limit": { + "description": "Optional turn page size.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional turn pagination direction; defaults to descending." + } + }, + "type": "object" + }, "ThreadResumeParams": { "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { @@ -3947,6 +3985,12 @@ ], "description": "Override where approval requests are routed for review on this turn and subsequent turns." }, + "clientUserMessageId": { + "type": [ + "string", + "null" + ] + }, "cwd": { "description": "Override the working directory for this turn and subsequent turns.", "type": [ @@ -4033,6 +4077,12 @@ }, "TurnSteerParams": { "properties": { + "clientUserMessageId": { + "type": [ + "string", + "null" + ] + }, "expectedTurnId": { "description": "Required active turn id precondition. The request fails when it does not match the currently active turn.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 90899cb152..b0501b0e9e 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2031,6 +2031,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -3559,6 +3561,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" 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 ab19f3e770..80c2053a5e 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 @@ -10160,6 +10160,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -10962,6 +10964,47 @@ "title": "McpResourceReadResponse", "type": "object" }, + "McpServerInfo": { + "description": "Presentation metadata advertised by an initialized MCP server.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, "McpServerMigration": { "properties": { "name": { @@ -11072,6 +11115,16 @@ }, "type": "array" }, + "serverInfo": { + "anyOf": [ + { + "$ref": "#/definitions/v2/McpServerInfo" + }, + { + "type": "null" + } + ] + }, "tools": { "additionalProperties": { "$ref": "#/definitions/v2/Tool" @@ -15943,6 +15996,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/v2/UserInput" @@ -17148,6 +17207,42 @@ "title": "ThreadRealtimeTranscriptDoneNotification", "type": "object" }, + "ThreadResumeInitialTurnsPageParams": { + "properties": { + "itemsView": { + "anyOf": [ + { + "$ref": "#/definitions/v2/TurnItemsView" + }, + { + "type": "null" + } + ], + "description": "How much item detail to include for each returned turn; defaults to summary." + }, + "limit": { + "description": "Optional turn page size.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional turn pagination direction; defaults to descending." + } + }, + "type": "object" + }, "ThreadResumeParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", @@ -18324,6 +18419,12 @@ ], "description": "Override where approval requests are routed for review on this turn and subsequent turns." }, + "clientUserMessageId": { + "type": [ + "string", + "null" + ] + }, "cwd": { "description": "Override the working directory for this turn and subsequent turns.", "type": [ @@ -18451,6 +18552,12 @@ "TurnSteerParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "clientUserMessageId": { + "type": [ + "string", + "null" + ] + }, "expectedTurnId": { "description": "Required active turn id precondition. The request fails when it does not match the currently active turn.", "type": "string" @@ -18486,6 +18593,32 @@ "title": "TurnSteerResponse", "type": "object" }, + "TurnsPage": { + "properties": { + "backwardsCursor": { + "type": [ + "string", + "null" + ] + }, + "data": { + "items": { + "$ref": "#/definitions/v2/Turn" + }, + "type": "array" + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "type": "object" + }, "UserInput": { "oneOf": [ { 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 b12b0eedd8..8fc70ab734 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 @@ -6640,6 +6640,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -7491,6 +7493,47 @@ "title": "McpResourceReadResponse", "type": "object" }, + "McpServerInfo": { + "description": "Presentation metadata advertised by an initialized MCP server.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, "McpServerMigration": { "properties": { "name": { @@ -7601,6 +7644,16 @@ }, "type": "array" }, + "serverInfo": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerInfo" + }, + { + "type": "null" + } + ] + }, "tools": { "additionalProperties": { "$ref": "#/definitions/Tool" @@ -13767,6 +13820,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -14972,6 +15031,42 @@ "title": "ThreadRealtimeTranscriptDoneNotification", "type": "object" }, + "ThreadResumeInitialTurnsPageParams": { + "properties": { + "itemsView": { + "anyOf": [ + { + "$ref": "#/definitions/TurnItemsView" + }, + { + "type": "null" + } + ], + "description": "How much item detail to include for each returned turn; defaults to summary." + }, + "limit": { + "description": "Optional turn page size.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional turn pagination direction; defaults to descending." + } + }, + "type": "object" + }, "ThreadResumeParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", @@ -16148,6 +16243,12 @@ ], "description": "Override where approval requests are routed for review on this turn and subsequent turns." }, + "clientUserMessageId": { + "type": [ + "string", + "null" + ] + }, "cwd": { "description": "Override the working directory for this turn and subsequent turns.", "type": [ @@ -16275,6 +16376,12 @@ "TurnSteerParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "clientUserMessageId": { + "type": [ + "string", + "null" + ] + }, "expectedTurnId": { "description": "Required active turn id precondition. The request fails when it does not match the currently active turn.", "type": "string" @@ -16310,6 +16417,32 @@ "title": "TurnSteerResponse", "type": "object" }, + "TurnsPage": { + "properties": { + "backwardsCursor": { + "type": [ + "string", + "null" + ] + }, + "data": { + "items": { + "$ref": "#/definitions/Turn" + }, + "type": "array" + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "type": "object" + }, "UserInput": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 87d36d2927..8ba25f9a07 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -287,6 +287,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -498,6 +500,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index d45fddf4ba..70fc43f7b8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -287,6 +287,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -498,6 +500,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json index fc181c2702..0dc2f5e2e4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json @@ -10,6 +10,47 @@ ], "type": "string" }, + "McpServerInfo": { + "description": "Presentation metadata advertised by an initialized MCP server.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, "McpServerStatus": { "properties": { "authStatus": { @@ -30,6 +71,16 @@ }, "type": "array" }, + "serverInfo": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerInfo" + }, + { + "type": "null" + } + ] + }, "tools": { "additionalProperties": { "$ref": "#/definitions/Tool" diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index e69b342282..f82acbae64 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -165,6 +165,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 877eeb1278..a644ce8c4e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -424,6 +424,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -642,6 +644,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 8c45f57d38..ec46130357 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -529,6 +529,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -1119,6 +1121,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index a9a186228b..5ace3f7af7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -450,6 +450,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -934,6 +936,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index a2a4834cb3..a4cc7d91ff 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -450,6 +450,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -934,6 +936,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 5d76f9b181..234e0867f8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -450,6 +450,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -934,6 +936,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index d4bdeda0ed..364cb36617 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -224,6 +224,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -981,6 +983,74 @@ "danger-full-access" ], "type": "string" + }, + "SortDirection": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "ThreadResumeInitialTurnsPageParams": { + "properties": { + "itemsView": { + "anyOf": [ + { + "$ref": "#/definitions/TurnItemsView" + }, + { + "type": "null" + } + ], + "description": "How much item detail to include for each returned turn; defaults to summary." + }, + "limit": { + "description": "Optional turn page size.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional turn pagination direction; defaults to descending." + } + }, + "type": "object" + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] } }, "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 8055ff58fc..59ed236c48 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -529,6 +529,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -1119,6 +1121,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -1993,6 +2001,32 @@ ], "type": "string" }, + "TurnsPage": { + "properties": { + "backwardsCursor": { + "type": [ + "string", + "null" + ] + }, + "data": { + "items": { + "$ref": "#/definitions/Turn" + }, + "type": "array" + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "type": "object" + }, "UserInput": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index d0e25a1eaa..7815f261c0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -450,6 +450,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -934,6 +936,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index d456ccebc4..ca52f10ca5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -529,6 +529,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -1119,6 +1121,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index dd7de34b82..f6cb72b6b2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -450,6 +450,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -934,6 +936,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 5929510520..e4e317e608 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -450,6 +450,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -934,6 +936,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index ddaab863f7..6c64eb3275 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -424,6 +424,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -642,6 +644,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index ffc130dcc9..f997976aca 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -123,6 +123,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -514,6 +516,12 @@ ], "description": "Override where approval requests are routed for review on this turn and subsequent turns." }, + "clientUserMessageId": { + "type": [ + "string", + "null" + ] + }, "cwd": { "description": "Override the working directory for this turn and subsequent turns.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index a2836f7f03..5fe9754576 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -424,6 +424,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -642,6 +644,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 8c57890a3b..1db1497218 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -424,6 +424,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -642,6 +644,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json index ef05b27673..63317f13ba 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json @@ -44,6 +44,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], @@ -216,6 +218,12 @@ } }, "properties": { + "clientUserMessageId": { + "type": [ + "string", + "null" + ] + }, "expectedTurnId": { "description": "Required active turn id precondition. The request fails when it does not match the currently active turn.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts b/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts index 5a62cc32f1..a48f07c088 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ImageDetail.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 ImageDetail = "high" | "original"; +export type ImageDetail = "auto" | "low" | "high" | "original"; diff --git a/codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts b/codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts new file mode 100644 index 0000000000..a3f6b0e142 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Presentation metadata advertised by an initialized MCP server. + */ +export type McpServerInfo = { name: string, title: string | null, version: string, description: string | null, icons: Array | null, websiteUrl: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 8be75af546..458d2e43b9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -42,6 +42,7 @@ export type { InternalSessionSource } from "./InternalSessionSource"; export type { LocalShellAction } from "./LocalShellAction"; export type { LocalShellExecAction } from "./LocalShellExecAction"; export type { LocalShellStatus } from "./LocalShellStatus"; +export type { McpServerInfo } from "./McpServerInfo"; export type { MessagePhase } from "./MessagePhase"; export type { ModeKind } from "./ModeKind"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts index 430494e268..d2e99ce96f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts @@ -1,9 +1,10 @@ // 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 { McpServerInfo } from "../McpServerInfo"; import type { Resource } from "../Resource"; import type { ResourceTemplate } from "../ResourceTemplate"; import type { Tool } from "../Tool"; import type { McpAuthStatus } from "./McpAuthStatus"; -export type McpServerStatus = { name: string, tools: { [key in string]?: Tool }, resources: Array, resourceTemplates: Array, authStatus: McpAuthStatus, }; +export type McpServerStatus = { name: string, serverInfo: McpServerInfo | null, tools: { [key in string]?: Tool }, resources: Array, resourceTemplates: Array, authStatus: McpAuthStatus, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index 1b655b460e..78dff0d765 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -23,7 +23,7 @@ import type { PatchApplyStatus } from "./PatchApplyStatus"; import type { UserInput } from "./UserInput"; import type { WebSearchAction } from "./WebSearchAction"; -export type ThreadItem = { "type": "userMessage", id: string, content: Array, } | { "type": "hookPrompt", id: string, fragments: Array, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array, content: Array, } | { "type": "commandExecution", id: string, +export type ThreadItem = { "type": "userMessage", id: string, clientId: string | null, content: Array, } | { "type": "hookPrompt", id: string, fragments: Array, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array, content: Array, } | { "type": "commandExecution", id: string, /** * The command to be executed. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeInitialTurnsPageParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeInitialTurnsPageParams.ts new file mode 100644 index 0000000000..2dbcd97807 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeInitialTurnsPageParams.ts @@ -0,0 +1,19 @@ +// 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 { SortDirection } from "./SortDirection"; +import type { TurnItemsView } from "./TurnItemsView"; + +export type ThreadResumeInitialTurnsPageParams = { +/** + * Optional turn page size. + */ +limit?: number | null, +/** + * Optional turn pagination direction; defaults to descending. + */ +sortDirection?: SortDirection | null, +/** + * How much item detail to include for each returned turn; defaults to summary. + */ +itemsView?: TurnItemsView | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index b04919d86b..afe1ac6d94 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -10,7 +10,7 @@ import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { UserInput } from "./UserInput"; -export type TurnStartParams = {threadId: string, input: Array, /** +export type TurnStartParams = {threadId: string, clientUserMessageId?: string | null, input: Array, /** * Override the working directory for this turn and subsequent turns. */ cwd?: string | null, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnSteerParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnSteerParams.ts index dae166b401..a984f2cba8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnSteerParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnSteerParams.ts @@ -3,7 +3,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { UserInput } from "./UserInput"; -export type TurnSteerParams = {threadId: string, input: Array, /** +export type TurnSteerParams = {threadId: string, clientUserMessageId?: string | null, input: Array, /** * Required active turn id precondition. The request fails when it does not * match the currently active turn. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnsPage.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnsPage.ts new file mode 100644 index 0000000000..e91865aeeb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnsPage.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Turn } from "./Turn"; + +export type TurnsPage = { data: Array, nextCursor: string | null, backwardsCursor: string | null, }; 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 2a4220cb60..0b7316fc4f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -398,6 +398,7 @@ export type { ThreadRealtimeStartTransport } from "./ThreadRealtimeStartTranspor export type { ThreadRealtimeStartedNotification } from "./ThreadRealtimeStartedNotification"; export type { ThreadRealtimeTranscriptDeltaNotification } from "./ThreadRealtimeTranscriptDeltaNotification"; export type { ThreadRealtimeTranscriptDoneNotification } from "./ThreadRealtimeTranscriptDoneNotification"; +export type { ThreadResumeInitialTurnsPageParams } from "./ThreadResumeInitialTurnsPageParams"; export type { ThreadResumeParams } from "./ThreadResumeParams"; export type { ThreadResumeResponse } from "./ThreadResumeResponse"; export type { ThreadRollbackParams } from "./ThreadRollbackParams"; @@ -450,6 +451,7 @@ export type { TurnStartedNotification } from "./TurnStartedNotification"; export type { TurnStatus } from "./TurnStatus"; export type { TurnSteerParams } from "./TurnSteerParams"; export type { TurnSteerResponse } from "./TurnSteerResponse"; +export type { TurnsPage } from "./TurnsPage"; export type { UserInput } from "./UserInput"; export type { WarningNotification } from "./WarningNotification"; export type { WebSearchAction } from "./WebSearchAction"; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index fcaa8a1eb5..56cf7038f2 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -948,10 +948,8 @@ impl ScanState { '(' => self.depth.paren += 1, ')' => self.depth.paren = (self.depth.paren - 1).max(0), '<' => self.depth.angle += 1, - '>' => { - if self.depth.angle > 0 { - self.depth.angle -= 1; - } + '>' if self.depth.angle > 0 => { + self.depth.angle -= 1; } _ => {} } @@ -2212,20 +2210,14 @@ mod tests { continue; } match ch { - '\\' => { - if in_single || in_double { - escape = true; - } + '\\' if (in_single || in_double) => { + escape = true; } - '\'' => { - if !in_double { - in_single = !in_single; - } + '\'' if !in_double => { + in_single = !in_single; } - '"' => { - if !in_single { - in_double = !in_double; - } + '"' if !in_single => { + in_double = !in_double; } '{' if !in_single && !in_double => level_brace += 1, '}' if !in_single && !in_double => level_brace -= 1, 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 c0c7d55528..aa65f9ab9d 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -278,7 +278,11 @@ impl ThreadHistoryBuilder { .unwrap_or_else(|| self.new_turn(/*id*/ None)); let id = self.next_item_id(); let content = self.build_user_inputs(payload); - turn.items.push(ThreadItem::UserMessage { id, content }); + turn.items.push(ThreadItem::UserMessage { + id, + client_id: payload.client_id.clone(), + content, + }); self.current_turn = Some(turn); } @@ -1246,6 +1250,7 @@ mod tests { fn builds_multiple_turns_with_reasoning_items() { let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "First turn".into(), images: Some(vec!["https://example.com/one.png".into()]), text_elements: Vec::new(), @@ -1264,6 +1269,7 @@ mod tests { text: "full reasoning".into(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Second turn".into(), images: None, text_elements: Vec::new(), @@ -1292,6 +1298,7 @@ mod tests { first.items[0], ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![ UserInput::Text { text: "First turn".into(), @@ -1330,6 +1337,7 @@ mod tests { second.items[0], ThreadItem::UserMessage { id: "item-4".into(), + client_id: None, content: vec![UserInput::Text { text: "Second turn".into(), text_elements: Vec::new(), @@ -1352,6 +1360,7 @@ mod tests { let local_path = PathBuf::from("/tmp/local.png"); let events = vec![RolloutItem::EventMsg(EventMsg::UserMessage( UserMessageEvent { + client_id: None, message: "inspect these".into(), images: Some(vec!["https://example.com/image.png".into()]), image_details: vec![Some(ImageDetail::Original)], @@ -1368,6 +1377,7 @@ mod tests { turns[0].items[0], ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![ UserInput::Text { text: "inspect these".into(), @@ -1399,6 +1409,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "hello".into(), images: None, text_elements: Vec::new(), @@ -1410,6 +1421,7 @@ mod tests { turn_id: turn_id.to_string(), item: CoreTurnItem::UserMessage(CoreUserMessageItem { id: "user-item-id".to_string(), + client_id: None, content: Vec::new(), }), started_at_ms: 0, @@ -1434,6 +1446,7 @@ mod tests { turns[0].items[0], ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1442,6 +1455,67 @@ mod tests { ); } + #[test] + fn preserves_user_message_client_id_from_legacy_event() { + let turn_id = "turn-1"; + let thread_id = ThreadId::new(); + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: turn_id.to_string(), + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::ItemStarted(ItemStartedEvent { + thread_id, + turn_id: turn_id.to_string(), + item: CoreTurnItem::UserMessage(CoreUserMessageItem { + id: "user-item-id".to_string(), + client_id: Some("client-message-1".to_string()), + content: vec![codex_protocol::user_input::UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + }), + started_at_ms: 0, + }), + EventMsg::UserMessage(UserMessageEvent { + client_id: Some("client-message-1".to_string()), + message: "hello".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + ..Default::default() + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!( + turns[0].items, + vec![ThreadItem::UserMessage { + id: "item-1".into(), + client_id: Some("client-message-1".to_string()), + content: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + }] + ); + } + #[test] fn preserves_agent_message_phase_in_history() { let events = vec![EventMsg::AgentMessage(AgentMessageEvent { @@ -1478,6 +1552,7 @@ mod tests { collaboration_mode_kind: Default::default(), })), RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "generate an image".into(), images: None, text_elements: Vec::new(), @@ -1515,6 +1590,7 @@ mod tests { items: vec![ ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![UserInput::Text { text: "generate an image".into(), text_elements: Vec::new(), @@ -1536,6 +1612,7 @@ mod tests { fn splits_reasoning_when_interleaved() { let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Turn start".into(), images: None, text_elements: Vec::new(), @@ -1589,6 +1666,7 @@ mod tests { fn marks_turn_as_interrupted_when_aborted() { let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Please do the thing".into(), images: None, text_elements: Vec::new(), @@ -1607,6 +1685,7 @@ mod tests { duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Let's try again".into(), images: None, text_elements: Vec::new(), @@ -1634,6 +1713,7 @@ mod tests { first_turn.items[0], ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![UserInput::Text { text: "Please do the thing".into(), text_elements: Vec::new(), @@ -1657,6 +1737,7 @@ mod tests { second_turn.items[0], ThreadItem::UserMessage { id: "item-3".into(), + client_id: None, content: vec![UserInput::Text { text: "Let's try again".into(), text_elements: Vec::new(), @@ -1678,6 +1759,7 @@ mod tests { fn drops_last_turns_on_thread_rollback() { let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "First".into(), images: None, text_elements: Vec::new(), @@ -1690,6 +1772,7 @@ mod tests { memory_citation: None, }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Second".into(), images: None, text_elements: Vec::new(), @@ -1703,6 +1786,7 @@ mod tests { }), EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Third".into(), images: None, text_elements: Vec::new(), @@ -1732,6 +1816,7 @@ mod tests { vec![ ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![UserInput::Text { text: "First".into(), text_elements: Vec::new(), @@ -1750,6 +1835,7 @@ mod tests { vec![ ThreadItem::UserMessage { id: "item-3".into(), + client_id: None, content: vec![UserInput::Text { text: "Third".into(), text_elements: Vec::new(), @@ -1769,6 +1855,7 @@ mod tests { fn thread_rollback_clears_all_turns_when_num_turns_exceeds_history() { let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "One".into(), images: None, text_elements: Vec::new(), @@ -1781,6 +1868,7 @@ mod tests { memory_citation: None, }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Two".into(), images: None, text_elements: Vec::new(), @@ -1814,6 +1902,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Start".into(), images: None, text_elements: Vec::new(), @@ -1821,6 +1910,7 @@ mod tests { ..Default::default() }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Steer".into(), images: None, text_elements: Vec::new(), @@ -1848,6 +1938,7 @@ mod tests { vec![ ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![UserInput::Text { text: "Start".into(), text_elements: Vec::new(), @@ -1855,6 +1946,7 @@ mod tests { }, ThreadItem::UserMessage { id: "item-2".into(), + client_id: None, content: vec![UserInput::Text { text: "Steer".into(), text_elements: Vec::new(), @@ -1875,6 +1967,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "run tools".into(), images: None, text_elements: Vec::new(), @@ -2054,6 +2147,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "run dynamic tool".into(), images: None, text_elements: Vec::new(), @@ -2121,6 +2215,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "run tools".into(), images: None, text_elements: Vec::new(), @@ -2212,6 +2307,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "review this command".into(), images: None, text_elements: Vec::new(), @@ -2297,6 +2393,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "run a subcommand".into(), images: None, text_elements: Vec::new(), @@ -2362,6 +2459,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "first".into(), images: None, text_elements: Vec::new(), @@ -2383,6 +2481,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "second".into(), images: None, text_elements: Vec::new(), @@ -2458,6 +2557,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "first".into(), images: None, text_elements: Vec::new(), @@ -2479,6 +2579,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "second".into(), images: None, text_elements: Vec::new(), @@ -2528,6 +2629,7 @@ mod tests { turns[1].items[0], ThreadItem::UserMessage { id: "item-2".into(), + client_id: None, content: vec![UserInput::Text { text: "second".into(), text_elements: Vec::new(), @@ -2549,6 +2651,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "apply patch".into(), images: None, text_elements: Vec::new(), @@ -2584,6 +2687,7 @@ mod tests { vec![ ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![UserInput::Text { text: "apply patch".into(), text_elements: Vec::new(), @@ -2615,6 +2719,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "apply patch".into(), images: None, text_elements: Vec::new(), @@ -2652,6 +2757,7 @@ mod tests { vec![ ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![UserInput::Text { text: "apply patch".into(), text_elements: Vec::new(), @@ -2681,6 +2787,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "first".into(), images: None, text_elements: Vec::new(), @@ -2702,6 +2809,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "second".into(), images: None, text_elements: Vec::new(), @@ -2751,6 +2859,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "first".into(), images: None, text_elements: Vec::new(), @@ -2772,6 +2881,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "second".into(), images: None, text_elements: Vec::new(), @@ -2846,6 +2956,7 @@ mod tests { fn reconstructs_collab_resume_end_item() { let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "resume agent".into(), images: None, text_elements: Vec::new(), @@ -2904,6 +3015,7 @@ mod tests { .expect("valid receiver thread id"); let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "spawn agent".into(), images: None, text_elements: Vec::new(), @@ -2966,6 +3078,7 @@ mod tests { .expect("valid receiver thread id"); let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "redirect".into(), images: None, text_elements: Vec::new(), @@ -3030,6 +3143,7 @@ mod tests { fn rollback_failed_error_does_not_mark_turn_failed() { let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "hello".into(), images: None, text_elements: Vec::new(), @@ -3068,6 +3182,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "hello".into(), images: None, text_elements: Vec::new(), @@ -3105,6 +3220,7 @@ mod tests { items_view: TurnItemsView::Full, items: vec![ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -3125,6 +3241,7 @@ mod tests { collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "hello".into(), images: None, text_elements: Vec::new(), @@ -3184,6 +3301,7 @@ mod tests { collaboration_mode_kind: Default::default(), })), RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "hello".into(), images: None, text_elements: Vec::new(), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index 39e489d6aa..d68485565e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -212,7 +212,11 @@ impl CommandAction { pub enum ThreadItem { #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] - UserMessage { id: String, content: Vec }, + UserMessage { + id: String, + client_id: Option, + content: Vec, + }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] HookPrompt { @@ -776,6 +780,7 @@ impl From for ThreadItem { match value { CoreTurnItem::UserMessage(user) => ThreadItem::UserMessage { id: user.id, + client_id: user.client_id, content: user.content.into_iter().map(UserInput::from).collect(), }, CoreTurnItem::HookPrompt(hook_prompt) => ThreadItem::HookPrompt { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs index 5f92e83059..ae61f12b2d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -2,6 +2,7 @@ use super::shared::v2_enum_from_core; use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; use codex_protocol::items::McpToolCallError as CoreMcpToolCallError; use codex_protocol::mcp::CallToolResult as CoreMcpCallToolResult; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::mcp::Resource as McpResource; pub use codex_protocol::mcp::ResourceContent as McpResourceContent; use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; @@ -53,6 +54,7 @@ pub enum McpServerStatusDetail { #[ts(export_to = "v2/")] pub struct McpServerStatus { pub name: String, + pub server_info: Option, pub tools: std::collections::HashMap, pub resources: Vec, pub resource_templates: Vec, @@ -690,6 +692,7 @@ impl From for rmcp::model::CreateElicitatio Self { action: value.action.into(), content: value.content, + meta: None, } } } 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 c2b4f24b51..4fcb9a2c92 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -11,6 +11,7 @@ use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::items::WebSearchItem; use codex_protocol::mcp::CallToolResult; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; @@ -111,6 +112,85 @@ fn thread_turns_list_params_accepts_items_view() { assert_eq!(params.items_view, Some(TurnItemsView::NotLoaded)); } +#[test] +fn thread_resume_params_accept_turns_page_bootstrap() { + let params = serde_json::from_value::(json!({ + "threadId": "thr_123", + "initialTurnsPage": { + "limit": 25, + "sortDirection": "asc", + "itemsView": "full", + }, + })) + .expect("thread resume params should deserialize"); + + assert_eq!(params.thread_id, "thr_123"); + assert_eq!( + params.initial_turns_page, + Some(ThreadResumeInitialTurnsPageParams { + limit: Some(25), + sort_direction: Some(SortDirection::Asc), + items_view: Some(TurnItemsView::Full), + }) + ); +} + +#[test] +fn thread_resume_response_round_trips_initial_turns_page() { + let response = ThreadResumeResponse { + thread: Thread { + id: "thr_123".to_string(), + session_id: "thr_123".to_string(), + forked_from_id: None, + preview: String::new(), + ephemeral: false, + model_provider: "openai".to_string(), + created_at: 1, + updated_at: 1, + status: ThreadStatus::Idle, + path: None, + cwd: absolute_path("tmp"), + cli_version: "0.0.0".to_string(), + source: SessionSource::Exec, + thread_source: None, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: Vec::new(), + }, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: absolute_path("tmp"), + runtime_workspace_roots: Vec::new(), + instruction_sources: Vec::new(), + approval_policy: AskForApproval::OnFailure, + approvals_reviewer: ApprovalsReviewer::User, + sandbox: SandboxPolicy::DangerFullAccess, + active_permission_profile: None, + reasoning_effort: None, + initial_turns_page: Some(TurnsPage { + data: Vec::new(), + next_cursor: Some("cursor_next".to_string()), + backwards_cursor: Some("cursor_back".to_string()), + }), + }; + + let value = serde_json::to_value(&response).expect("serialize thread resume response"); + assert_eq!( + value.get("initialTurnsPage"), + Some(&json!({ + "data": [], + "nextCursor": "cursor_next", + "backwardsCursor": "cursor_back", + })) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize thread resume response"); + assert_eq!(decoded, response); +} + #[test] fn thread_turns_items_list_round_trips() { let params = ThreadTurnsItemsListParams { @@ -1676,6 +1756,7 @@ fn client_request_turn_start_granular_approval_policy_is_marked_experimental() { request_id: crate::RequestId::Integer(4), params: TurnStartParams { thread_id: "thr_123".to_string(), + client_user_message_id: None, input: Vec::new(), approval_policy: Some(AskForApproval::Granular { sandbox_approval: false, @@ -1699,6 +1780,7 @@ fn mcp_server_elicitation_response_round_trips_rmcp_result() { content: Some(json!({ "confirmed": true, })), + meta: None, }; let v2_response = McpServerElicitationRequestResponse::from(rmcp_result.clone()); @@ -1923,6 +2005,80 @@ fn mcp_server_elicitation_response_serializes_nullable_content() { ); } +#[test] +fn mcp_server_status_serializes_absent_server_info_as_null() { + let response = ListMcpServerStatusResponse { + data: vec![McpServerStatus { + name: "not-ready".to_string(), + server_info: None, + tools: HashMap::new(), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: McpAuthStatus::Unsupported, + }], + next_cursor: None, + }; + + assert_eq!( + serde_json::to_value(response).expect("response should serialize"), + json!({ + "data": [{ + "name": "not-ready", + "serverInfo": null, + "tools": {}, + "resources": [], + "resourceTemplates": [], + "authStatus": "unsupported", + }], + "nextCursor": null, + }) + ); +} + +#[test] +fn mcp_server_status_serializes_absent_server_info_metadata_as_null() { + let response = ListMcpServerStatusResponse { + data: vec![McpServerStatus { + name: "initialized".to_string(), + server_info: Some(McpServerInfo { + name: "lookup-server".to_string(), + title: None, + version: "1.0.0".to_string(), + description: None, + icons: None, + website_url: None, + }), + tools: HashMap::new(), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: McpAuthStatus::Unsupported, + }], + next_cursor: None, + }; + + assert_eq!( + serde_json::to_value(response).expect("response should serialize"), + json!({ + "data": [{ + "name": "initialized", + "serverInfo": { + "name": "lookup-server", + "title": null, + "version": "1.0.0", + "description": null, + "icons": null, + "websiteUrl": null, + }, + "tools": {}, + "resources": [], + "resourceTemplates": [], + "authStatus": "unsupported", + }], + "nextCursor": null, + }) + ); +} + #[test] fn sandbox_policy_round_trips_workspace_write_access() { let v2_policy = SandboxPolicy::WorkspaceWrite { @@ -2159,6 +2315,7 @@ fn network_requirements_serializes_canonical_and_legacy_fields() { fn core_turn_item_into_thread_item_converts_supported_variants() { let user_item = TurnItem::UserMessage(UserMessageItem { id: "user-1".to_string(), + client_id: Some("client-message-1".to_string()), content: vec![ CoreUserInput::Text { text: "hello".to_string(), @@ -2187,6 +2344,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { ThreadItem::from(user_item), ThreadItem::UserMessage { id: "user-1".to_string(), + client_id: Some("client-message-1".to_string()), content: vec![ UserInput::Text { text: "hello".to_string(), @@ -3405,6 +3563,7 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { assert_eq!(fork.instruction_sources, Vec::::new()); assert_eq!(start.active_permission_profile, None); assert_eq!(resume.active_permission_profile, None); + assert_eq!(resume.initial_turns_page, None); assert_eq!(fork.active_permission_profile, None); } @@ -3426,6 +3585,7 @@ fn turn_start_params_preserve_explicit_null_service_tier() { let without_override = TurnStartParams { thread_id: "thread_123".to_string(), + client_user_message_id: None, input: vec![], responsesapi_client_metadata: None, additional_context: 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 4971d5f4b8..8157fce69f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -397,6 +397,11 @@ pub struct ThreadResumeParams { #[experimental("thread/resume.excludeTurns")] #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub exclude_turns: bool, + /// When present, include a `thread/turns/list` page in the resume response + /// so clients can bootstrap recent turns without a second request. + #[experimental("thread/resume.initialTurnsPage")] + #[ts(optional = nullable)] + pub initial_turns_page: Option, /// 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. @@ -435,6 +440,44 @@ pub struct ThreadResumeResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, + /// `thread/turns/list` page returned when requested by `initialTurnsPage`. + #[experimental("thread/resume.initialTurnsPage")] + #[serde(default)] + pub initial_turns_page: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadResumeInitialTurnsPageParams { + /// Optional turn page size. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional turn pagination direction; defaults to descending. + #[ts(optional = nullable)] + pub sort_direction: Option, + /// How much item detail to include for each returned turn; defaults to summary. + #[ts(optional = nullable)] + pub items_view: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnsPage { + pub data: Vec, + pub next_cursor: Option, + pub backwards_cursor: Option, +} + +impl From for TurnsPage { + fn from(response: ThreadTurnsListResponse) -> Self { + Self { + data: response.data, + next_cursor: response.next_cursor, + backwards_cursor: response.backwards_cursor, + } + } } #[derive( diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index ab5e59a464..f9bfe21479 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -65,6 +65,8 @@ pub struct AdditionalContextEntry { #[ts(export_to = "v2/")] pub struct TurnStartParams { pub thread_id: String, + #[ts(optional = nullable)] + pub client_user_message_id: Option, pub input: Vec, /// Optional turn-scoped Responses API client metadata. #[experimental("turn/start.responsesapiClientMetadata")] @@ -157,6 +159,8 @@ pub struct TurnStartResponse { #[ts(export_to = "v2/")] pub struct TurnSteerParams { pub thread_id: String, + #[ts(optional = nullable)] + pub client_user_message_id: Option, pub input: Vec, /// Optional turn-scoped Responses API client metadata. #[experimental("turn/steer.responsesapiClientMetadata")] diff --git a/codex-rs/app-server-protocol/src/schema_fixtures.rs b/codex-rs/app-server-protocol/src/schema_fixtures.rs index 447fcf8cfe..18ed557ddf 100644 --- a/codex-rs/app-server-protocol/src/schema_fixtures.rs +++ b/codex-rs/app-server-protocol/src/schema_fixtures.rs @@ -196,7 +196,7 @@ fn canonicalize_json(value: &Value) -> Value { } Value::Object(map) => { let mut entries: Vec<_> = map.iter().collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + entries.sort_by_key(|(key, _)| *key); let mut sorted = Map::with_capacity(map.len()); for (key, child) in entries { sorted.insert(key.clone(), canonicalize_json(child)); diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index fd9ecc845d..e89a8dbe99 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -734,6 +734,7 @@ async fn trigger_zsh_fork_multi_cmd_approval( let mut turn_params = TurnStartParams { thread_id: thread_response.thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: message, text_elements: Vec::new(), @@ -818,6 +819,7 @@ async fn resume_message_v2( let turn_response = client.turn_start(TurnStartParams { thread_id: resume_response.thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: user_message, text_elements: Vec::new(), @@ -959,6 +961,7 @@ async fn send_message_v2_with_policies( println!("< thread/start response: {thread_response:?}"); let mut turn_params = TurnStartParams { thread_id: thread_response.thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: user_message, // Test client sends plain text without UI element ranges. @@ -999,6 +1002,7 @@ async fn send_follow_up_v2( let first_turn_params = TurnStartParams { thread_id: thread_response.thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: first_message, // Test client sends plain text without UI element ranges. @@ -1012,6 +1016,7 @@ async fn send_follow_up_v2( let follow_up_params = TurnStartParams { thread_id: thread_response.thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: follow_up_message, // Test client sends plain text without UI element ranges. @@ -1255,6 +1260,7 @@ fn live_elicitation_timeout_pause( let started_at = Instant::now(); let turn_response = client.turn_start(TurnStartParams { thread_id: thread_id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: prompt, text_elements: Vec::new(), 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 6358d592e4..95bc9d3c6f 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 @@ -1390,14 +1390,13 @@ pub(super) async fn connect_remote_control_websocket( status_publisher.publish_environment_id(/*environment_id*/ None); } tungstenite::Error::Http(response) - if matches!(response.status().as_u16(), 401 | 403) => + if matches!(response.status().as_u16(), 401 | 403) + && recover_remote_control_auth(auth_recovery, auth_change_rx).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() - ))); - } + return Err(io::Error::other(format!( + "remote control websocket auth failed with HTTP {}; retrying after auth recovery", + response.status() + ))); } _ => {} } diff --git a/codex-rs/app-server-transport/src/transport/websocket.rs b/codex-rs/app-server-transport/src/transport/websocket.rs index 177e378906..5677faae67 100644 --- a/codex-rs/app-server-transport/src/transport/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/websocket.rs @@ -347,7 +347,7 @@ async fn run_websocket_inbound_loop( incoming_message = websocket_reader.next() => { match incoming_message { Some(Ok(message)) => match message.into_incoming() { - Some(IncomingWebSocketMessage::Text(text)) => { + Some(IncomingWebSocketMessage::Text(text)) if !forward_incoming_message( &transport_event_tx, &writer_tx_for_reader, @@ -355,10 +355,10 @@ async fn run_websocket_inbound_loop( &text, ) .await - { - break; - } + => { + break; } + Some(IncomingWebSocketMessage::Text(_)) => {} Some(IncomingWebSocketMessage::Ping(payload)) => { match writer_control_tx.try_send(M::pong(payload)) { Ok(()) => {} diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 17ecd7dc3f..d1a69783ad 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -54,6 +54,7 @@ codex-backend-client = { workspace = true } codex-file-search = { workspace = true } codex-chatgpt = { workspace = true } codex-login = { workspace = true } +codex-image-generation-extension = { workspace = true } codex-memories-extension = { workspace = true } codex-web-search-extension = { workspace = true } codex-memories-write = { workspace = true } @@ -94,6 +95,9 @@ tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] } uuid = { workspace = true, features = ["serde", "v7"] } +[target.'cfg(windows)'.dependencies] +codex-windows-sandbox = { workspace = true } + [dev-dependencies] app_test_support = { workspace = true } axum = { workspace = true, default-features = false, features = [ diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index db76e363ec..ad3767bb77 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -23,7 +23,7 @@ Similar to [MCP](https://modelcontextprotocol.io/), `codex app-server` supports Supported transports: -- stdio (`--listen stdio://`, default): newline-delimited JSON (JSONL) +- stdio (`--stdio` or `--listen stdio://`, default): newline-delimited JSON (JSONL) - websocket (`--listen ws://IP:PORT`): one JSON-RPC message per websocket text frame (**experimental / unsupported**) - unix socket (`--listen unix://` or `--listen unix://PATH`): websocket connections over `$CODEX_HOME/app-server-control/app-server-control.sock` or a custom socket path, using the standard HTTP Upgrade handshake - off (`--listen off`): do not expose a local transport @@ -158,9 +158,9 @@ Example with notification opt-out: - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective turn cwd. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective turn cwd. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. -- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`. +- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. - `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. - `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`. @@ -192,7 +192,7 @@ Example with notification opt-out: - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. -- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). @@ -216,7 +216,7 @@ Example with notification opt-out: - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. -- `mcpServerStatus/list` — enumerate configured MCP servers with their tools and auth status, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`. +- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, auth status, server info, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`. - `mcpServer/resource/read` — read a resource from a configured MCP server by optional `threadId`, `server`, and `uri`, returning text/blob resource `contents`. If `threadId` is omitted, the server reads from the latest MCP config directly. - `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. @@ -281,6 +281,8 @@ To continue a stored session, call `thread/resume` with the `thread.id` you prev By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Experimental clients can pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if they want to page the turn history over the network. In that mode the server also skips replaying restored `thread/tokenUsage/updated`, which avoids rebuilding turns just to attribute historical usage. +Experimental clients that want the live resume subscription plus a turns page in one round trip can pass `initialTurnsPage`. It accepts the same `limit`, `sortDirection`, and `itemsView` controls as `thread/turns/list`; omitted controls use its defaults. The response includes `initialTurnsPage` with `nextCursor` and `backwardsCursor` for follow-up pagination. + By default, resume uses the latest persisted `model` and `reasoningEffort` values associated with the thread. Supplying any of `model`, `modelProvider`, `config.model`, or `config.model_reasoning_effort` disables that persisted fallback and uses the explicit overrides plus normal config resolution instead. Example: @@ -297,6 +299,24 @@ Example: "excludeTurns": true } } { "id": 12, "result": { "thread": { "id": "thr_123", "turns": [], … } } } + +{ "method": "thread/resume", "id": 13, "params": { + "threadId": "thr_123", + "excludeTurns": true, + "initialTurnsPage": { + "limit": 20, + "sortDirection": "desc", + "itemsView": "summary" + } +} } +{ "id": 13, "result": { + "thread": { "id": "thr_123", "turns": [], … }, + "initialTurnsPage": { + "data": [ ... ], + "nextCursor": "older-turns-cursor-or-null", + "backwardsCursor": "newer-turns-cursor-or-null" + } +} } ``` To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. The returned `thread.sessionId` identifies the current live session tree root. Root threads use their own `thread.id` as `thread.sessionId`; stored threads that are not loaded also report their own `thread.id`, because resuming one makes it the root of a new live session tree. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only: @@ -642,6 +662,7 @@ You can optionally specify config overrides on the new turn. If specified, these ```json { "method": "turn/start", "id": 30, "params": { "threadId": "thr_123", + "clientUserMessageId": "client_msg_123", "input": [ { "type": "text", "text": "Run tests" } ], // Below are optional config overrides "cwd": "/Users/me/project", @@ -840,6 +861,7 @@ not emit `turn/started` and does not accept thread settings overrides. ```json { "method": "turn/steer", "id": 32, "params": { "threadId": "thr_123", + "clientUserMessageId": "client_msg_124", "input": [ { "type": "text", "text": "Actually focus on failing tests first." } ], "expectedTurnId": "turn_456" } } @@ -1225,7 +1247,7 @@ Today both notifications carry an empty `items` array even when item events were `ThreadItem` is the tagged union carried in turn responses and `item/*` notifications. Currently we support events for the following items: -- `userMessage` — `{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`). +- `userMessage` — `{id, clientId, content}` where `clientId` is the optional `clientUserMessageId` supplied to `turn/start` or `turn/steer`, and `content` is a list of user inputs (`text`, `image`, or `localImage`). - `agentMessage` — `{id, text}` containing the accumulated agent reply. - `plan` — `{id, text}` emitted for plan-mode turns; plan text can stream via `item/plan/delta` (experimental). - `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models). diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 5f784ea23a..fd220ae86d 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2153,6 +2153,7 @@ mod tests { let created_at = Utc::now(); let history_items = vec![ RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "before rollback".to_string(), images: None, local_images: Vec::new(), @@ -3228,6 +3229,7 @@ mod tests { state.track_current_turn_event( "turn-1", &EventMsg::UserMessage(codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "already tracked".to_string(), images: None, local_images: Vec::new(), diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index 1246da7b37..7b2673ca06 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -31,7 +31,8 @@ where let mut builder = ExtensionRegistryBuilder::::with_event_sink(event_sink); codex_guardian::install(&mut builder, guardian_agent_spawner); codex_memories_extension::install(&mut builder, codex_otel::global()); - codex_web_search_extension::install(&mut builder, auth_manager); + codex_web_search_extension::install(&mut builder, auth_manager.clone()); + codex_image_generation_extension::install(&mut builder, auth_manager); Arc::new(builder.build()) } diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 97c497b877..94b5a1af27 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -653,6 +653,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { params: TurnStartParams { environments: None, thread_id, + client_user_message_id: None, input: vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 6cab439bcc..e8fffd5361 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -456,8 +456,8 @@ impl OutgoingMessageSender { ) -> Vec { let request_id_to_callback = self.request_id_to_callback.lock().await; let mut requests = request_id_to_callback - .iter() - .filter_map(|(_, entry)| { + .values() + .filter_map(|entry| { (entry.thread_id == Some(thread_id)).then_some(entry.request.clone()) }) .collect::>(); diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 66ab8e3a1d..8408a3f67f 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -213,6 +213,7 @@ use codex_app_server_protocol::ThreadRealtimeStartResponse; use codex_app_server_protocol::ThreadRealtimeStartTransport; use codex_app_server_protocol::ThreadRealtimeStopParams; use codex_app_server_protocol::ThreadRealtimeStopResponse; +use codex_app_server_protocol::ThreadResumeInitialTurnsPageParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadRollbackParams; 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 72948270a6..39dbd17fe6 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -54,6 +54,7 @@ const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ "mentions_v2", "plugins", "remote_control", + "remote_plugin", "tool_suggest", "tool_call_mcp_elicitation", ]; diff --git a/codex-rs/app-server/src/request_processors/feedback_processor.rs b/codex-rs/app-server/src/request_processors/feedback_processor.rs index 18f519d3c0..73da67f735 100644 --- a/codex-rs/app-server/src/request_processors/feedback_processor.rs +++ b/codex-rs/app-server/src/request_processors/feedback_processor.rs @@ -1,4 +1,6 @@ use super::*; +#[cfg(target_os = "windows")] +use codex_feedback::WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME; #[derive(Clone)] pub(crate) struct FeedbackRequestProcessor { @@ -186,6 +188,12 @@ impl FeedbackRequestProcessor { )), }); } + if let Some(sandbox_log_attachment) = + windows_sandbox_log_attachment(&self.config.codex_home) + && seen_attachment_paths.insert(sandbox_log_attachment.path.clone()) + { + attachment_paths.push(sandbox_log_attachment); + } } if let Some(extra_log_files) = extra_log_files { for extra_log_file in extra_log_files { @@ -264,3 +272,46 @@ impl FeedbackRequestProcessor { fn auto_review_rollout_filename(thread_id: ThreadId) -> String { format!("auto-review-rollout-{thread_id}.jsonl") } + +#[cfg(target_os = "windows")] +fn windows_sandbox_log_attachment(codex_home: &Path) -> Option { + let sandbox_log_path = codex_windows_sandbox::current_log_file_path_for_codex_home(codex_home); + sandbox_log_path + .is_file() + .then_some(FeedbackAttachmentPath { + path: sandbox_log_path, + attachment_filename_override: Some(WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME.to_string()), + }) +} + +#[cfg(not(target_os = "windows"))] +fn windows_sandbox_log_attachment(_codex_home: &Path) -> Option { + None +} + +#[cfg(all(test, target_os = "windows"))] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn windows_sandbox_log_attachment_uses_current_log() { + let codex_home = tempfile::tempdir().expect("create tempdir"); + let sandbox_dir = codex_windows_sandbox::sandbox_dir(codex_home.path()); + std::fs::create_dir_all(&sandbox_dir).expect("create sandbox dir"); + let sandbox_log_path = + codex_windows_sandbox::current_log_file_path_for_codex_home(codex_home.path()); + std::fs::write(&sandbox_log_path, "sandbox log").expect("write sandbox log"); + + let attachment = windows_sandbox_log_attachment(codex_home.path()) + .map(|attachment| (attachment.path, attachment.attachment_filename_override)); + + assert_eq!( + attachment, + Some(( + sandbox_log_path, + Some(WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME.to_string()) + )) + ); + } +} diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index 22d5a4326a..ae62e2e785 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -276,6 +276,7 @@ impl McpRequestProcessor { .await; let McpServerStatusSnapshot { + server_infos, tools_by_server, resources, resource_templates, @@ -315,6 +316,7 @@ impl McpRequestProcessor { .iter() .map(|name| McpServerStatus { name: name.clone(), + server_info: server_infos.get(name).cloned(), tools: tools_by_server.get(name).cloned().unwrap_or_default(), resources: resources.get(name).cloned().unwrap_or_default(), resource_templates: resource_templates.get(name).cloned().unwrap_or_default(), diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index 985baac91a..f72cfb5a62 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -565,8 +565,28 @@ pub(super) async fn handle_pending_thread_resume_request( has_live_in_progress_turn, ); let token_usage_thread = pending.include_turns.then(|| thread.clone()); + let mut initial_turns_page = if let Some(params) = pending.initial_turns_page.as_ref() { + match super::thread_processor::build_thread_resume_initial_turns_page( + &pending.history_items, + thread.status.clone(), + has_live_in_progress_turn, + active_turn, + params, + ) { + Ok(page) => Some(page), + Err(error) => { + outgoing.send_error(request_id, error).await; + return; + } + } + } else { + None + }; if pending.redact_resume_payloads { - redact_thread_resume_payloads(&mut thread); + redact_thread_resume_payloads(&mut thread.turns); + if let Some(initial_turns_page) = initial_turns_page.as_mut() { + redact_thread_resume_payloads(&mut initial_turns_page.data); + } } { @@ -635,6 +655,7 @@ pub(super) async fn handle_pending_thread_resume_request( sandbox, active_permission_profile, reasoning_effort, + initial_turns_page, }; outgoing.send_response(request_id, response).await; // Match cold resume: metadata-only resume should attach the listener without diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 5a2e1a4de8..939c3df519 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2241,8 +2241,6 @@ impl ThreadRequestProcessor { sort_direction, items_view, } = params; - let items_view = items_view.unwrap_or(TurnItemsView::Summary); - let thread_uuid = ThreadId::from_string(&thread_id) .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; @@ -2270,60 +2268,20 @@ impl ThreadRequestProcessor { } else { None }; - let mut turns = reconstruct_thread_turns_for_turns_list( + build_thread_turns_page_response( &items, self.thread_watch_manager .loaded_status_for_thread(&thread_uuid.to_string()) .await, has_live_running_thread, active_turn, - ); - for turn in &mut turns { - match items_view { - TurnItemsView::NotLoaded => { - turn.items.clear(); - turn.items_view = TurnItemsView::NotLoaded; - } - TurnItemsView::Summary => { - let first_user_message = turn - .items - .iter() - .find(|item| matches!(item, ThreadItem::UserMessage { .. })) - .cloned(); - let final_agent_message = turn - .items - .iter() - .rev() - .find(|item| matches!(item, ThreadItem::AgentMessage { .. })) - .cloned(); - turn.items = match (first_user_message, final_agent_message) { - (Some(user_message), Some(agent_message)) - if user_message.id() != agent_message.id() => - { - vec![user_message, agent_message] - } - (Some(user_message), _) => vec![user_message], - (None, Some(agent_message)) => vec![agent_message], - (None, None) => Vec::new(), - }; - turn.items_view = TurnItemsView::Summary; - } - TurnItemsView::Full => { - turn.items_view = TurnItemsView::Full; - } - } - } - let page = paginate_thread_turns( - turns, - cursor.as_deref(), - limit, - sort_direction.unwrap_or(SortDirection::Desc), - )?; - Ok(ThreadTurnsListResponse { - data: page.turns, - next_cursor: page.next_cursor, - backwards_cursor: page.backwards_cursor, - }) + ThreadTurnsPageOptions { + cursor: cursor.as_deref(), + limit, + sort_direction: sort_direction.unwrap_or(SortDirection::Desc), + items_view: items_view.unwrap_or(TurnItemsView::Summary), + }, + ) } async fn load_thread_turns_list_history( @@ -2531,6 +2489,7 @@ impl ThreadRequestProcessor { developer_instructions, personality, exclude_turns, + initial_turns_page, persist_extended_history: _persist_extended_history, } = params; let include_turns = !exclude_turns; @@ -2685,8 +2644,28 @@ impl ThreadRequestProcessor { config_snapshot.active_permission_profile, ); let token_usage_thread = include_turns.then(|| thread.clone()); + let mut initial_turns_page = if let Some(params) = initial_turns_page.as_ref() { + match build_thread_resume_initial_turns_page( + &response_history.get_rollout_items(), + thread.status.clone(), + /*has_live_running_thread*/ false, + /*active_turn*/ None, + params, + ) { + Ok(page) => Some(page), + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + } + } else { + None + }; if redact_resume_payloads { - redact_thread_resume_payloads(&mut thread); + redact_thread_resume_payloads(&mut thread.turns); + if let Some(initial_turns_page) = initial_turns_page.as_mut() { + redact_thread_resume_payloads(&mut initial_turns_page.data); + } } let response = ThreadResumeResponse { @@ -2702,6 +2681,7 @@ impl ThreadRequestProcessor { sandbox, active_permission_profile, reasoning_effort: session_configured.reasoning_effort, + initial_turns_page, }; let connection_id = request_id.connection_id; @@ -2926,6 +2906,7 @@ impl ThreadRequestProcessor { emit_thread_goal_update, thread_goal_state_db, include_turns: !params.exclude_turns, + initial_turns_page: params.initial_turns_page.clone(), redact_resume_payloads, }), ); @@ -3706,6 +3687,95 @@ fn parse_thread_turns_cursor(cursor: &str) -> Result { + cursor: Option<&'a str>, + limit: Option, + sort_direction: SortDirection, + items_view: TurnItemsView, +} + +fn build_thread_turns_page_response( + items: &[RolloutItem], + loaded_status: ThreadStatus, + has_live_running_thread: bool, + active_turn: Option, + options: ThreadTurnsPageOptions<'_>, +) -> Result { + let mut turns = reconstruct_thread_turns_for_turns_list( + items, + loaded_status, + has_live_running_thread, + active_turn, + ); + apply_thread_turns_items_view(&mut turns, options.items_view); + let page = paginate_thread_turns(turns, options.cursor, options.limit, options.sort_direction)?; + Ok(ThreadTurnsListResponse { + data: page.turns, + next_cursor: page.next_cursor, + backwards_cursor: page.backwards_cursor, + }) +} + +pub(super) fn build_thread_resume_initial_turns_page( + items: &[RolloutItem], + loaded_status: ThreadStatus, + has_live_running_thread: bool, + active_turn: Option, + params: &ThreadResumeInitialTurnsPageParams, +) -> Result { + build_thread_turns_page_response( + items, + loaded_status, + has_live_running_thread, + active_turn, + ThreadTurnsPageOptions { + cursor: None, + limit: params.limit, + sort_direction: params.sort_direction.unwrap_or(SortDirection::Desc), + items_view: params.items_view.unwrap_or(TurnItemsView::Summary), + }, + ) + .map(Into::into) +} + +fn apply_thread_turns_items_view(turns: &mut [Turn], items_view: TurnItemsView) { + for turn in turns { + match items_view { + TurnItemsView::NotLoaded => { + turn.items.clear(); + turn.items_view = TurnItemsView::NotLoaded; + } + TurnItemsView::Summary => { + let first_user_message = turn + .items + .iter() + .find(|item| matches!(item, ThreadItem::UserMessage { .. })) + .cloned(); + let final_agent_message = turn + .items + .iter() + .rev() + .find(|item| matches!(item, ThreadItem::AgentMessage { .. })) + .cloned(); + turn.items = match (first_user_message, final_agent_message) { + (Some(user_message), Some(agent_message)) + if user_message.id() != agent_message.id() => + { + vec![user_message, agent_message] + } + (Some(user_message), _) => vec![user_message], + (None, Some(agent_message)) => vec![agent_message], + (None, None) => Vec::new(), + }; + turn.items_view = TurnItemsView::Summary; + } + TurnItemsView::Full => { + turn.items_view = TurnItemsView::Full; + } + } + } +} + fn reconstruct_thread_turns_for_turns_list( items: &[RolloutItem], loaded_status: ThreadStatus, 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 ab8f52ffc0..b5d49cf7fb 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 @@ -213,6 +213,7 @@ mod thread_processor_behavior_tests { fn thread_turns_list_merges_in_progress_active_turn_before_agent_status_running() { let persisted_items = vec![RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "persisted".to_string(), images: None, local_images: Vec::new(), @@ -224,6 +225,7 @@ mod thread_processor_behavior_tests { id: "live-turn".to_string(), items: vec![ThreadItem::UserMessage { id: "live-user-message".to_string(), + client_id: None, content: vec![V2UserInput::Text { text: "live".to_string(), text_elements: Vec::new(), @@ -652,6 +654,7 @@ mod thread_processor_behavior_tests { developer_instructions: None, personality: None, exclude_turns: false, + initial_turns_page: None, persist_extended_history: false, }; let config_snapshot = ThreadConfigSnapshot { diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index b1e8608842..b265e3a632 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -1,6 +1,6 @@ use codex_app_server_protocol::McpToolCallResult; -use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::Turn; use serde_json::Value as JsonValue; // Temporary bandaid for remote clients: thread/resume can include large MCP and @@ -14,8 +14,8 @@ pub(super) fn should_redact_thread_resume_payloads(client_name: Option<&str>) -> client_name.is_some_and(|client_name| CHATGPT_REMOTE_CLIENT_NAMES.contains(&client_name)) } -pub(super) fn redact_thread_resume_payloads(thread: &mut Thread) { - for turn in &mut thread.turns { +pub(super) fn redact_thread_resume_payloads(turns: &mut [Turn]) { + for turn in turns { turn.items.retain_mut(|item| match item { ThreadItem::McpToolCall { arguments, @@ -55,8 +55,8 @@ mod tests { use codex_app_server_protocol::McpToolCallError; use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::SessionSource; + use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadStatus; - use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStatus; use codex_utils_absolute_path::test_support::PathBufExt; @@ -100,7 +100,7 @@ mod tests { }, ]); - redact_thread_resume_payloads(&mut thread); + redact_thread_resume_payloads(&mut thread.turns); assert_eq!(thread.turns[0].items.len(), 2); assert_eq!( @@ -146,7 +146,7 @@ mod tests { duration_ms: Some(8), }]); - redact_thread_resume_payloads(&mut thread); + redact_thread_resume_payloads(&mut thread.turns); assert_eq!( thread.turns[0].items[0], diff --git a/codex-rs/app-server/src/request_processors/token_usage_replay.rs b/codex-rs/app-server/src/request_processors/token_usage_replay.rs index b8c65645fc..d4d2228d4b 100644 --- a/codex-rs/app-server/src/request_processors/token_usage_replay.rs +++ b/codex-rs/app-server/src/request_processors/token_usage_replay.rs @@ -148,6 +148,7 @@ mod tests { fn token_usage_history() -> Vec { vec![ RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "first turn".to_string(), images: None, local_images: Vec::new(), @@ -164,6 +165,7 @@ mod tests { rate_limits: None, })), RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "second turn".to_string(), images: None, local_images: Vec::new(), diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 975ff22d07..2fbc64c398 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -416,6 +416,7 @@ impl TurnRequestProcessor { .into_iter() .map(V2UserInput::into_core) .collect(); + let client_user_message_id = params.client_user_message_id; let additional_context = map_additional_context(params.additional_context); let turn_has_input = !mapped_items.is_empty(); let thread_settings = self @@ -448,8 +449,12 @@ impl TurnRequestProcessor { additional_context, thread_settings, }; - let turn_id = self - .submit_core_op(&request_id, thread.as_ref(), turn_op) + let turn_id = thread + .submit_user_input_with_client_user_message_id( + turn_op, + self.request_trace_context(&request_id).await, + client_user_message_id, + ) .await .map_err(|err| { let error = internal_error(format!("failed to start turn: {err}")); @@ -780,6 +785,7 @@ impl TurnRequestProcessor { mapped_items, additional_context, Some(¶ms.expected_turn_id), + params.client_user_message_id, params.responsesapi_client_metadata, ) .await @@ -989,6 +995,7 @@ impl TurnRequestProcessor { } else { vec![ThreadItem::UserMessage { id: turn_id.clone(), + client_id: None, content: vec![V2UserInput::Text { text: display_text.to_string(), // Review prompt display text is synthesized; no UI element ranges to preserve. 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 0b639b7d5c..c507bf8ddf 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 @@ -76,10 +76,8 @@ impl WindowsSandboxRequestProcessor { Ok(config) => { let setup_request = WindowsSandboxSetupRequest { mode, - policy: config - .permissions - .legacy_sandbox_policy(config.cwd.as_path()), - policy_cwd: config.cwd.to_path_buf(), + permission_profile: config.permissions.effective_permission_profile(), + permission_profile_cwd: config.cwd.to_path_buf(), command_cwd, env_map: std::env::vars().collect(), codex_home: config.codex_home.to_path_buf(), diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 7f864265b2..2d932f0326 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -35,6 +35,8 @@ pub(crate) struct PendingThreadResumeRequest { pub(crate) emit_thread_goal_update: bool, pub(crate) thread_goal_state_db: Option, pub(crate) include_turns: bool, + pub(crate) initial_turns_page: + Option, pub(crate) redact_resume_payloads: bool, } diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index a15b46abd9..8eb55e8715 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -506,6 +506,7 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> { let turn_req = mcp .send_turn_start_request(codex_app_server_protocol::TurnStartParams { thread_id: thread.thread.id, + client_user_message_id: None, input: vec![codex_app_server_protocol::UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -612,6 +613,7 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> { let turn_req = mcp .send_turn_start_request(codex_app_server_protocol::TurnStartParams { thread_id: thread.thread.id.clone(), + client_user_message_id: None, input: vec![codex_app_server_protocol::UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -734,6 +736,7 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { let turn_req = mcp .send_turn_start_request(codex_app_server_protocol::TurnStartParams { thread_id: thread.thread.id.clone(), + client_user_message_id: None, input: vec![codex_app_server_protocol::UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -849,6 +852,7 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> { let turn_req = mcp .send_turn_start_request(codex_app_server_protocol::TurnStartParams { thread_id: thread.thread.id.clone(), + client_user_message_id: None, input: vec![codex_app_server_protocol::UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index c6f95af95d..8e8f2a7557 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -168,11 +168,13 @@ pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { pub(crate) fn assert_basic_thread_initialized_event( event: &Value, thread_id: &str, + session_id: &str, expected_model: &str, initialization_mode: &str, expected_thread_source: &str, ) { assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["session_id"], session_id); assert_eq!( event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 395dff5668..039fffe5cd 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -316,6 +316,73 @@ connectors = false Ok(()) } +#[tokio::test] +async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> { + let connectors = vec![AppInfo { + id: "beta".to_string(), + name: "Beta".to_string(), + description: Some("Beta connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let mut app_only_tool = connector_tool("beta", "Beta App")?; + app_only_tool + .meta + .as_mut() + .expect("connector tool should include metadata") + .0 + .insert("ui".to_string(), json!({ "visibility": ["app"] })); + let tools = vec![app_only_tool]; + let (server_url, server_handle) = + start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-app-only") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let AppsListResponse { data, next_cursor } = to_response(response)?; + + assert_eq!(data.len(), 1); + assert_eq!(data[0].id, "beta"); + assert!(data[0].is_accessible); + assert!(next_cursor.is_none()); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + #[tokio::test] async fn list_apps_reports_is_enabled_from_config() -> Result<()> { let connectors = vec![AppInfo { @@ -1432,10 +1499,7 @@ impl AppsServerControl { impl ServerHandler for AppListMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } fn list_tools( diff --git a/codex-rs/app-server/tests/suite/v2/attestation.rs b/codex-rs/app-server/tests/suite/v2/attestation.rs index d0565e2571..a662755be9 100644 --- a/codex-rs/app-server/tests/suite/v2/attestation.rs +++ b/codex-rs/app-server/tests/suite/v2/attestation.rs @@ -110,6 +110,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand let turn_request_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/client_metadata.rs b/codex-rs/app-server/tests/suite/v2/client_metadata.rs index 3a00a433d1..687efefbdf 100644 --- a/codex-rs/app-server/tests/suite/v2/client_metadata.rs +++ b/codex-rs/app-server/tests/suite/v2/client_metadata.rs @@ -73,6 +73,7 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -105,6 +106,10 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result assert_eq!(metadata["thread_source"].as_str(), Some("client-supplied")); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); assert!(metadata.get("session_id").is_some()); + assert_eq!( + metadata["window_id"].as_str(), + request.header("x-codex-window-id").as_deref() + ); Ok(()) } @@ -149,6 +154,7 @@ async fn turn_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() -> let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Continue".to_string(), text_elements: Vec::new(), @@ -329,6 +335,7 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2() let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Run sleep".to_string(), text_elements: Vec::new(), @@ -359,6 +366,7 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2() let steer_req = mcp .send_turn_steer_request(TurnSteerParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Focus on the failure".to_string(), text_elements: Vec::new(), @@ -454,6 +462,7 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -497,6 +506,10 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body assert_eq!(metadata["origin"].as_str(), Some("gaas")); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); assert!(metadata.get("session_id").is_some()); + assert_eq!( + metadata["window_id"].as_str(), + request["client_metadata"]["x-codex-window-id"].as_str() + ); websocket_server.shutdown().await; Ok(()) diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index 6db031b278..ca46c88a28 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -190,6 +190,58 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<() let response_requests = responses_log.requests(); assert_eq!(response_requests.len(), 3); + let turn_metadata = response_requests + .iter() + .map(|request| { + request + .header("x-codex-turn-metadata") + .as_deref() + .map(parse_json_header) + .unwrap_or_else(|| panic!("turn request should include turn metadata")) + }) + .collect::>(); + for (request, metadata) in response_requests.iter().zip(&turn_metadata) { + assert_eq!(metadata["request_kind"].as_str(), Some("turn")); + assert!( + metadata["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()), + "turn request should carry a non-empty turn id" + ); + assert_eq!( + metadata["window_id"].as_str(), + request.header("x-codex-window-id").as_deref() + ); + assert!(metadata.get("compaction").is_none()); + } + + let compact_metadata = compact_requests[0] + .header("x-codex-turn-metadata") + .as_deref() + .map(parse_json_header) + .unwrap_or_else(|| panic!("compact request should include turn metadata")); + assert_eq!( + compact_metadata["request_kind"].as_str(), + Some("compaction") + ); + assert_eq!( + compact_metadata["compaction"], + serde_json::json!({ + "trigger": "auto", + "reason": "context_limit", + "implementation": "responses_compact", + "phase": "pre_turn", + "strategy": "memento", + }) + ); + assert_eq!( + compact_metadata["turn_id"], turn_metadata[2]["turn_id"], + "pre-turn compaction should carry the current turn id" + ); + assert_eq!( + compact_metadata["window_id"].as_str(), + compact_requests[0].header("x-codex-window-id").as_deref() + ); Ok(()) } @@ -342,6 +394,7 @@ async fn send_turn_and_wait(mcp: &mut McpProcess, thread_id: &str, text: &str) - let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread_id.to_string(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: text.to_string(), text_elements: Vec::new(), @@ -407,3 +460,7 @@ async fn wait_for_context_compaction_completed( } } } + +fn parse_json_header(value: &str) -> serde_json::Value { + serde_json::from_str(value).unwrap_or_else(|err| panic!("turn metadata should be json: {err}")) +} diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index cf76b9d573..0e00bc6309 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -758,12 +758,15 @@ pub(super) async fn read_response_and_notification_for_method( JSONRPCMessage::Response(candidate) if candidate.id == target_id => { response = Some(candidate); } + JSONRPCMessage::Notification(candidate) + if candidate.method == method && notification.is_some() => + { + bail!( + "received duplicate notification for method `{method}` before completing paired read" + ); + } JSONRPCMessage::Notification(candidate) if candidate.method == method => { - if notification.replace(candidate).is_some() { - bail!( - "received duplicate notification for method `{method}` before completing paired read" - ); - } + notification = Some(candidate); } _ => {} } diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs index 4b708d4ab3..0c8a59a9c8 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs @@ -226,6 +226,7 @@ async fn send_turn_start_request(stream: &mut WsClient, id: i64, thread_id: &str id, Some(serde_json::to_value(TurnStartParams { thread_id: thread_id.to_string(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index b357e139db..333086c722 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -89,6 +89,7 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()> let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -168,6 +169,7 @@ async fn thread_start_keeps_hidden_dynamic_tools_out_of_model_requests() -> Resu let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -347,6 +349,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread_id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Run the tool".to_string(), text_elements: Vec::new(), @@ -521,6 +524,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread_id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Run the tool".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index d6101995fa..782dba3edf 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -340,6 +340,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re BTreeMap::from([ ("memories".to_string(), true), ("plugins".to_string(), true), + ("remote_plugin".to_string(), true), ("tool_suggest".to_string(), true), ("tool_call_mcp_elicitation".to_string(), false), ]), @@ -352,6 +353,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re enablement: BTreeMap::from([ ("memories".to_string(), true), ("plugins".to_string(), true), + ("remote_plugin".to_string(), true), ("tool_suggest".to_string(), true), ("tool_call_mcp_elicitation".to_string(), false), ]), @@ -381,6 +383,13 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .and_then(|features| features.get("plugins")), Some(&json!(true)) ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("remote_plugin")), + Some(&json!(true)) + ); assert_eq!( config .additional @@ -474,7 +483,7 @@ async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() - ); assert!( error.message.contains( - "apps, memories, mentions_v2, plugins, remote_control, tool_suggest, tool_call_mcp_elicitation" + "apps, memories, mentions_v2, plugins, remote_control, remote_plugin, tool_suggest, tool_call_mcp_elicitation" ), "{}", error.message diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index f5f74c0231..22790f94f7 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -391,6 +391,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { let request_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "follow up".to_string(), text_elements: Vec::new(), @@ -964,6 +965,7 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_ let request_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "follow up".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index 1c8f76fe51..7e0b39f0c6 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -662,6 +662,7 @@ command = "python3 {hook_script_path}" let first_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "first turn".to_string(), text_elements: Vec::new(), @@ -723,6 +724,7 @@ command = "python3 {hook_script_path}" let second_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "second turn".to_string(), text_elements: Vec::new(), @@ -792,6 +794,7 @@ command = "python3 {hook_script_path}" let third_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "third turn".to_string(), text_elements: Vec::new(), @@ -932,6 +935,7 @@ command = "python3 {hook_script_path}" let first_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "first turn".to_string(), text_elements: Vec::new(), @@ -983,6 +987,7 @@ command = "python3 {hook_script_path}" let second_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "second turn".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 47cacdb20e..3a9b22cf16 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -306,6 +306,7 @@ async fn turn_start_notify_payload_includes_initialize_client_name() -> Result<( let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index bddf57f666..db16aded20 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -288,11 +288,8 @@ struct ResourceAppsMcpServer; impl ServerHandler for ResourceAppsMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - protocol_version: ProtocolVersion::V_2025_06_18, - capabilities: ServerCapabilities::builder().enable_resources().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_resources().build()) + .with_protocol_version(ProtocolVersion::V_2025_06_18) } async fn read_resource( @@ -308,21 +305,19 @@ impl ServerHandler for ResourceAppsMcpServer { )); } - Ok(ReadResourceResult { - contents: vec![ - ResourceContents::TextResourceContents { - uri: TEST_RESOURCE_URI.to_string(), - mime_type: Some("text/markdown".to_string()), - text: TEST_RESOURCE_TEXT.to_string(), - meta: None, - }, - ResourceContents::BlobResourceContents { - uri: TEST_BLOB_RESOURCE_URI.to_string(), - mime_type: Some("application/octet-stream".to_string()), - blob: TEST_RESOURCE_BLOB.to_string(), - meta: None, - }, - ], - }) + Ok(ReadResourceResult::new(vec![ + ResourceContents::TextResourceContents { + uri: TEST_RESOURCE_URI.to_string(), + mime_type: Some("text/markdown".to_string()), + text: TEST_RESOURCE_TEXT.to_string(), + meta: None, + }, + ResourceContents::BlobResourceContents { + uri: TEST_BLOB_RESOURCE_URI.to_string(), + mime_type: Some("application/octet-stream".to_string()), + blob: TEST_RESOURCE_BLOB.to_string(), + meta: None, + }, + ])) } } diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index 13ebe0b99c..7357e3f178 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -135,6 +135,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { let warmup_turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Warm up connectors.".to_string(), text_elements: Vec::new(), @@ -167,6 +168,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Use [$calendar](app://calendar) to run the calendar tool.".to_string(), text_elements: Vec::new(), @@ -308,11 +310,8 @@ struct ElicitationAppsMcpServer; impl ServerHandler for ElicitationAppsMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - protocol_version: rmcp::model::ProtocolVersion::V_2025_06_18, - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_protocol_version(rmcp::model::ProtocolVersion::V_2025_06_18) } async fn list_tools( diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs index bc9839b530..32f98a1738 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs @@ -20,6 +20,7 @@ use codex_core::config::set_project_trust_level; use codex_protocol::config_types::TrustLevel; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; +use rmcp::model::Implementation; use rmcp::model::JsonObject; use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourcesResult; @@ -99,6 +100,13 @@ url = "{mcp_server_url}/mcp" .map(|tool| tool.name.as_str()), Some("look-up.raw") ); + assert_eq!( + status + .server_info + .as_ref() + .and_then(|info| info.title.as_deref()), + Some("Lookup Server") + ); mcp_server_handle.abort(); let _ = mcp_server_handle.await; @@ -205,10 +213,9 @@ struct McpStatusServer { impl ServerHandler for McpStatusServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_server_info( + Implementation::new("lookup-server", "1.0.0").with_title("Lookup Server"), + ) } async fn list_tools( @@ -244,13 +251,12 @@ struct SlowInventoryServer { impl ServerHandler for SlowInventoryServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_tools() .enable_resources() .build(), - ..ServerInfo::default() - } + ) } async fn list_tools( diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index a21dd0d9f0..9ba09b078f 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -461,6 +461,7 @@ url = "{mcp_server_url}/mcp" let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Call the large MCP tool".to_string(), text_elements: Vec::new(), @@ -537,10 +538,7 @@ struct ToolAppsMcpServer; impl ServerHandler for ToolAppsMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } async fn list_tools( diff --git a/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs b/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs index 6dcb9ac4ec..529f238798 100644 --- a/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs +++ b/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs @@ -60,7 +60,7 @@ async fn read_amazon_bedrock_provider_capabilities() -> Result<()> { let received: ModelProviderCapabilitiesReadResponse = to_response(response)?; let expected = ModelProviderCapabilitiesReadResponse { - namespace_tools: false, + namespace_tools: true, image_generation: false, web_search: false, }; diff --git a/codex-rs/app-server/tests/suite/v2/output_schema.rs b/codex-rs/app-server/tests/suite/v2/output_schema.rs index 149e098b68..86d12edcfc 100644 --- a/codex-rs/app-server/tests/suite/v2/output_schema.rs +++ b/codex-rs/app-server/tests/suite/v2/output_schema.rs @@ -59,6 +59,7 @@ async fn turn_start_accepts_output_schema_v2() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -141,6 +142,7 @@ async fn turn_start_output_schema_is_per_turn_v2() -> Result<()> { let turn_req_1 = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -183,6 +185,7 @@ async fn turn_start_output_schema_is_per_turn_v2() -> Result<()> { let turn_req_2 = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello again".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/plan_item.rs b/codex-rs/app-server/tests/suite/v2/plan_item.rs index 97e67fa090..63f7d9fba2 100644 --- a/codex-rs/app-server/tests/suite/v2/plan_item.rs +++ b/codex-rs/app-server/tests/suite/v2/plan_item.rs @@ -152,6 +152,7 @@ async fn start_plan_mode_turn(mcp: &mut McpProcess) -> Result ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } fn list_tools( diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 2a5d3d887a..26a8548166 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -1768,10 +1768,7 @@ struct PluginReadMcpServer { impl ServerHandler for PluginReadMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } fn list_tools( diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index 9787868f5c..158ebb25d1 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -113,6 +113,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste request_id: RequestId::Integer(2), params: TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -228,7 +229,6 @@ fn assert_no_local_persistence_artifacts(codex_home: &Path) -> Result<()> { BTreeSet::from([ "config.toml".to_string(), "installation_id".to_string(), - "memories".to_string(), "skills".to_string(), ]), "non-local thread persistence should not create unexpected files in codex_home" diff --git a/codex-rs/app-server/tests/suite/v2/request_permissions.rs b/codex-rs/app-server/tests/suite/v2/request_permissions.rs index 7acdc1b12f..8fd44d26f0 100644 --- a/codex-rs/app-server/tests/suite/v2/request_permissions.rs +++ b/codex-rs/app-server/tests/suite/v2/request_permissions.rs @@ -49,6 +49,7 @@ async fn request_permissions_round_trip() -> Result<()> { let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "pick a directory".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/request_user_input.rs b/codex-rs/app-server/tests/suite/v2/request_user_input.rs index f77ddfb4f7..a80b5527a7 100644 --- a/codex-rs/app-server/tests/suite/v2/request_user_input.rs +++ b/codex-rs/app-server/tests/suite/v2/request_user_input.rs @@ -51,6 +51,7 @@ async fn request_user_input_round_trip() -> Result<()> { let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "ask something".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/review.rs b/codex-rs/app-server/tests/suite/v2/review.rs index bf0271f821..bb89a68077 100644 --- a/codex-rs/app-server/tests/suite/v2/review.rs +++ b/codex-rs/app-server/tests/suite/v2/review.rs @@ -91,6 +91,7 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<() turn.items, vec![ThreadItem::UserMessage { id: turn_id.clone(), + client_id: None, content: vec![V2UserInput::Text { text: "commit 1234567: Tidy UI colors".to_string(), text_elements: Vec::new(), @@ -199,6 +200,7 @@ async fn review_start_exec_approval_item_id_matches_command_execution_item() -> turn.items, vec![ThreadItem::UserMessage { id: turn_id.clone(), + client_id: None, content: vec![V2UserInput::Text { text: "commit 1234567: Check review approvals".to_string(), text_elements: Vec::new(), @@ -328,6 +330,7 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<( turn.items, vec![ThreadItem::UserMessage { id: turn.id.clone(), + client_id: None, content: vec![V2UserInput::Text { text: "detached review".to_string(), text_elements: Vec::new(), @@ -465,6 +468,7 @@ async fn materialize_thread_rollout(mcp: &mut McpProcess, thread_id: &str) -> Re let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread_id.to_string(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "materialize rollout".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs index 7c1619e38a..a7f1bf3729 100644 --- a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs +++ b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs @@ -67,6 +67,7 @@ async fn openai_model_header_mismatch_emits_model_rerouted_notification_v2() -> let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "trigger safeguard".to_string(), text_elements: Vec::new(), @@ -133,6 +134,7 @@ async fn cyber_policy_response_emits_typed_error_notification_v2() -> Result<()> let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "trigger cyber policy error".to_string(), text_elements: Vec::new(), @@ -209,6 +211,7 @@ async fn response_model_field_mismatch_emits_model_rerouted_notification_v2_when let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "trigger response model check".to_string(), text_elements: Vec::new(), @@ -277,6 +280,7 @@ async fn model_verification_emits_typed_notification_and_warning_v2() -> Result< let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "trigger model verification".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_archive.rs b/codex-rs/app-server/tests/suite/v2/thread_archive.rs index b441a23cb6..6eadb1131b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_archive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_archive.rs @@ -93,6 +93,7 @@ async fn thread_archive_requires_materialized_rollout() -> Result<()> { let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "materialize".to_string(), text_elements: Vec::new(), @@ -517,6 +518,7 @@ async fn thread_archive_clears_stale_subscriptions_before_resume() -> Result<()> let turn_start_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "materialize".to_string(), text_elements: Vec::new(), @@ -594,6 +596,7 @@ async fn thread_archive_clears_stale_subscriptions_before_resume() -> Result<()> let resumed_turn_id = secondary .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![UserInput::Text { text: "secondary turn".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index cc89dd71cb..5c4c637f4b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -405,7 +405,14 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; let event = thread_initialized_event(&payload)?; - assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "forked", "user"); + assert_basic_thread_initialized_event( + event, + &thread.id, + &thread.session_id, + "mock-model", + "forked", + "user", + ); Ok(()) } @@ -740,6 +747,7 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<() let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: fork_thread_id, + client_user_message_id: None, input: vec![UserInput::Text { text: "continue".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs index 5a45e81e1d..c543b50498 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs @@ -92,6 +92,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -170,6 +171,7 @@ async fn thread_inject_items_adds_raw_response_items_after_a_turn() -> Result<() let first_turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "First turn".to_string(), text_elements: Vec::new(), @@ -215,6 +217,7 @@ async fn thread_inject_items_adds_raw_response_items_after_a_turn() -> Result<() let second_turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Second turn".to_string(), text_elements: Vec::new(), 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 e064ff6e25..8808092361 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -227,6 +227,7 @@ async fn thread_list_reports_system_error_idle_flag_after_failed_turn() -> Resul let seed_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -249,6 +250,7 @@ async fn thread_list_reports_system_error_idle_flag_after_failed_turn() -> Resul let failed_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "fail turn".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index c8e3e179fb..ee430f1593 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -24,6 +24,7 @@ use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadNameUpdatedNotification; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadResumeInitialTurnsPageParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadSetNameParams; @@ -631,6 +632,77 @@ async fn thread_read_can_return_archived_threads_by_id() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_initial_turns_page_matches_requested_turns_list_page() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let filename_ts = "2025-01-05T12-00-00"; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + filename_ts, + "2025-01-05T12:00:00Z", + "first", + vec![], + Some("mock_provider"), + /*git_info*/ None, + )?; + let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); + append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; + append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let turns_list_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: conversation_id.clone(), + cursor: None, + limit: Some(2), + sort_direction: Some(SortDirection::Asc), + items_view: Some(TurnItemsView::NotLoaded), + }) + .await?; + let turns_list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turns_list_id)), + ) + .await??; + let expected_page = to_response::(turns_list_resp)?; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id, + exclude_turns: true, + initial_turns_page: Some(ThreadResumeInitialTurnsPageParams { + limit: Some(2), + sort_direction: Some(SortDirection::Asc), + items_view: Some(TurnItemsView::NotLoaded), + }), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread, + initial_turns_page, + .. + } = to_response::(resume_resp)?; + + assert!(thread.turns.is_empty()); + assert_eq!( + initial_turns_page, + Some(codex_app_server_protocol::TurnsPage::from(expected_page)) + ); + + Ok(()) +} + #[tokio::test] async fn thread_turns_list_rejects_cursor_when_anchor_turn_is_rolled_back() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -1126,6 +1198,7 @@ async fn thread_read_reports_system_error_idle_flag_after_failed_turn() -> Resul let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "fail this turn".to_string(), text_elements: Vec::new(), @@ -1319,6 +1392,7 @@ async fn seed_pathless_store_thread( fn store_history_items() -> Vec { vec![RolloutItem::EventMsg(EventMsg::UserMessage( UserMessageEvent { + client_id: None, message: "history from store".to_string(), images: None, local_images: Vec::new(), 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 8e60c90a01..36dac0d45c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -37,6 +37,7 @@ use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; use codex_app_server_protocol::ThreadMetadataUpdateParams; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadResumeInitialTurnsPageParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadSource; @@ -210,6 +211,7 @@ async fn thread_resume_with_empty_path_uses_running_thread_id() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "materialize rollout".to_string(), text_elements: Vec::new(), @@ -278,6 +280,7 @@ async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Resul let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -422,7 +425,14 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; let event = thread_initialized_event(&payload)?; - assert_basic_thread_initialized_event(event, &thread.id, "gpt-5.3-codex", "resumed", "user"); + assert_basic_thread_initialized_event( + event, + &thread.id, + &thread.session_id, + "gpt-5.3-codex", + "resumed", + "user", + ); assert_eq!(event["event_params"]["thread_source"], "user"); Ok(()) } @@ -523,48 +533,59 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { #[tokio::test] async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<()> { for client_name in ["codex_chatgpt_android_remote", "codex_chatgpt_ios_remote"] { - let remote_thread = resume_redaction_fixture(Some(client_name)).await?; - let remote_turn = remote_thread + let remote_resume = resume_redaction_fixture(Some(client_name)).await?; + let remote_turn = remote_resume + .thread .turns .first() .expect("remote resume should include a turn"); - let remote_mcp_item = remote_turn - .items - .iter() - .find(|item| matches!(item, ThreadItem::McpToolCall { .. })) - .expect("remote resume should include redacted MCP item"); - let ThreadItem::McpToolCall { - arguments, - result, - error, - .. - } = remote_mcp_item - else { - unreachable!("matched MCP item"); - }; - assert_eq!(arguments, &json!("[redacted]")); - let result = result.as_ref().expect("redacted MCP result"); - assert_eq!( - result.content, - vec![json!({ - "type": "text", - "text": "[redacted]", - })] - ); - assert_eq!(result.structured_content, None); - assert_eq!(result.meta, None); - assert_eq!(error, &None); - assert!( - !remote_turn + let remote_page_turn = remote_resume + .initial_turns_page + .as_ref() + .expect("remote resume should include the requested initial turns page") + .data + .first() + .expect("remote initial turns page should include a turn"); + for remote_turn in [remote_turn, remote_page_turn] { + let remote_mcp_item = remote_turn .items .iter() - .any(|item| matches!(item, ThreadItem::ImageGeneration { .. })), - "remote resume should drop image generation items for {client_name}" - ); + .find(|item| matches!(item, ThreadItem::McpToolCall { .. })) + .expect("remote resume should include redacted MCP item"); + let ThreadItem::McpToolCall { + arguments, + result, + error, + .. + } = remote_mcp_item + else { + unreachable!("matched MCP item"); + }; + assert_eq!(arguments, &json!("[redacted]")); + let result = result.as_ref().expect("redacted MCP result"); + assert_eq!( + result.content, + vec![json!({ + "type": "text", + "text": "[redacted]", + })] + ); + assert_eq!(result.structured_content, None); + assert_eq!(result.meta, None); + assert_eq!(error, &None); + assert!( + !remote_turn + .items + .iter() + .any(|item| matches!(item, ThreadItem::ImageGeneration { .. })), + "remote resume should drop image generation items for {client_name}" + ); + } } - let normal_thread = resume_redaction_fixture(Some("some_other_client")).await?; - let normal_turn = normal_thread + let normal_resume = resume_redaction_fixture(Some("some_other_client")).await?; + let normal_turn = normal_resume + .thread .turns .first() .expect("normal resume should include a turn"); @@ -609,9 +630,7 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<( Ok(()) } -async fn resume_redaction_fixture( - client_name: Option<&str>, -) -> Result { +async fn resume_redaction_fixture(client_name: Option<&str>) -> Result { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -651,6 +670,11 @@ async fn resume_redaction_fixture( let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: conversation_id, + initial_turns_page: Some(ThreadResumeInitialTurnsPageParams { + limit: None, + sort_direction: None, + items_view: Some(TurnItemsView::Full), + }), ..Default::default() }) .await?; @@ -659,8 +683,7 @@ async fn resume_redaction_fixture( mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), ) .await??; - let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; - Ok(thread) + to_response::(resume_resp) } fn append_resume_redaction_history( @@ -788,6 +811,7 @@ async fn thread_resume_keeps_paused_goal_paused() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "materialize this thread".to_string(), text_elements: Vec::new(), @@ -892,6 +916,7 @@ async fn thread_goal_set_preserves_budget_limited_same_objective() -> Result<()> let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "materialize this thread".to_string(), text_elements: Vec::new(), @@ -990,6 +1015,7 @@ async fn thread_goal_set_persists_resumable_stopped_statuses() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "materialize this thread".to_string(), text_elements: Vec::new(), @@ -1185,6 +1211,7 @@ async fn thread_goal_clear_deletes_goal_and_notifies() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "materialize this thread".to_string(), text_elements: Vec::new(), @@ -2053,6 +2080,7 @@ async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> { let seed_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -2078,6 +2106,7 @@ async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> { let turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "respond with docs".to_string(), text_elements: Vec::new(), @@ -2160,6 +2189,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { let seed_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -2183,6 +2213,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { let running_turn_request_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread_id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "keep running".to_string(), text_elements: Vec::new(), @@ -2276,6 +2307,7 @@ async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result let seed_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -2299,6 +2331,7 @@ async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result let running_turn_request_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread_id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "keep running".to_string(), text_elements: Vec::new(), @@ -2412,6 +2445,7 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R let seed_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -2434,6 +2468,7 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R let running_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "keep running".to_string(), text_elements: Vec::new(), @@ -2441,11 +2476,13 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R ..Default::default() }) .await?; - timeout( + let running_turn_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)), ) .await??; + let TurnStartResponse { turn: running_turn } = + to_response::(running_turn_resp)?; timeout( DEFAULT_READ_TIMEOUT, primary.read_stream_until_notification_message("turn/started"), @@ -2457,6 +2494,11 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R thread_id: thread.id.clone(), model: Some("not-the-running-model".to_string()), cwd: Some("/tmp".to_string()), + initial_turns_page: Some(ThreadResumeInitialTurnsPageParams { + limit: None, + sort_direction: None, + items_view: None, + }), ..Default::default() }) .await?; @@ -2465,9 +2507,23 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R primary.read_stream_until_response_message(RequestId::Integer(resume_id)), ) .await??; - let ThreadResumeResponse { thread, model, .. } = - to_response::(resume_resp)?; + let ThreadResumeResponse { + thread, + model, + initial_turns_page, + .. + } = to_response::(resume_resp)?; assert_eq!(model, "gpt-5.4"); + let initial_turns_page = initial_turns_page.expect("resume should include initial turns page"); + let resumed_running_turn = initial_turns_page + .data + .first() + .expect("resume page should include the running turn"); + assert_eq!(resumed_running_turn.id, running_turn.id); + assert_eq!(resumed_running_turn.items_view, TurnItemsView::Summary); + assert_eq!(resumed_running_turn.status, TurnStatus::InProgress); + assert!(initial_turns_page.backwards_cursor.is_some()); + assert_eq!(initial_turns_page.next_cursor, None); // The running-thread resume response is queued onto the thread listener task. // If the in-flight turn completes before that queued command runs, the response // can legitimately observe the thread as idle. @@ -2520,6 +2576,7 @@ async fn thread_resume_can_skip_turns_when_thread_is_running() -> Result<()> { let turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -2603,6 +2660,7 @@ async fn thread_resume_replays_pending_command_execution_request_approval() -> R let seed_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -2625,6 +2683,7 @@ async fn thread_resume_replays_pending_command_execution_request_approval() -> R let running_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "run command".to_string(), text_elements: Vec::new(), @@ -2741,6 +2800,7 @@ async fn thread_resume_replays_pending_file_change_request_approval() -> Result< let seed_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -2764,6 +2824,7 @@ async fn thread_resume_replays_pending_file_change_request_approval() -> Result< let running_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "apply patch".to_string(), text_elements: Vec::new(), @@ -2908,6 +2969,7 @@ async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Re let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: resumed_thread.id, + client_user_message_id: None, input: vec![UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -3220,6 +3282,7 @@ async fn start_materialized_thread_and_restart( let materialize_turn_id = first_mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: seed_text.to_string(), text_elements: Vec::new(), @@ -3309,6 +3372,7 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { let materialize_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -3349,6 +3413,7 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { let turn_id = secondary .send_turn_start_request(TurnStartParams { thread_id: resume.thread.id, + client_user_message_id: None, input: vec![UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs index 5f79db0e26..80feacb4fe 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs @@ -57,6 +57,7 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<() let turn1_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: first_text.to_string(), text_elements: Vec::new(), @@ -78,6 +79,7 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<() let turn2_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Second".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs index aef2e961c0..5f7b8dbf21 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -259,6 +259,7 @@ async fn turn_start_settings_override_emits_thread_settings_updated() -> Result< let turn_request_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs index 4ab677aca1..368368badc 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs @@ -275,6 +275,7 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run python".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index ecd8ad8e3d..6be6d838ba 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -439,7 +439,14 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; assert_eq!(payload["events"].as_array().expect("events array").len(), 1); let event = thread_initialized_event(&payload)?; - assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "new", "user"); + assert_basic_thread_initialized_event( + event, + &thread.id, + &thread.session_id, + "mock-model", + "new", + "user", + ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_status.rs b/codex-rs/app-server/tests/suite/v2/thread_status.rs index 957969c3ea..51dda1bde7 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_status.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_status.rs @@ -48,6 +48,7 @@ async fn thread_status_changed_emits_runtime_updates() -> Result<()> { let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "collect status updates".to_string(), text_elements: Vec::new(), @@ -171,6 +172,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> { let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run once".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index d2f8f268a2..dea8433d44 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -81,6 +81,7 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "materialize".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs index 45437b7ee1..ebd3c9b95d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs @@ -151,6 +151,7 @@ async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread_id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run deterministic tool".to_string(), text_elements: Vec::new(), @@ -260,6 +261,7 @@ async fn thread_unsubscribe_preserves_cached_status_before_idle_unload() -> Resu let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread_id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "fail this turn".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index aedc54e016..78079ea148 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -78,6 +78,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run sleep".to_string(), text_elements: Vec::new(), @@ -159,6 +160,7 @@ async fn turn_interrupt_rejects_completed_turn() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "say done".to_string(), text_elements: Vec::new(), @@ -253,6 +255,7 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run python".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 88596b4639..2ae4561d86 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -141,6 +141,7 @@ async fn run_local_image_turn(detail: Option) -> Result> let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::LocalImage { path: image_path, detail, @@ -235,6 +236,7 @@ async fn turn_start_with_empty_input_runs_model_request() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: Vec::new(), ..Default::default() }) @@ -335,6 +337,7 @@ async fn turn_start_additional_context_flows_to_model_input() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "inspect tab".to_string(), text_elements: Vec::new(), @@ -420,6 +423,7 @@ async fn turn_start_sends_originator_header() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -492,6 +496,7 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: Some("client-message-1".to_string()), input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: text_elements.clone(), @@ -521,7 +526,10 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { .await??; match user_message_item { - ThreadItem::UserMessage { content, .. } => { + ThreadItem::UserMessage { + client_id, content, .. + } => { + assert_eq!(client_id, Some("client-message-1".to_string())); assert_eq!( content, vec![V2UserInput::Text { @@ -595,6 +603,7 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills( let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -761,6 +770,7 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -840,6 +850,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Image { url: "https://example.com/a.png".to_string(), detail: None, @@ -862,6 +873,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["session_id"], thread.session_id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( event["event_params"]["app_server_client"]["product_client_id"], @@ -927,6 +939,7 @@ async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![ V2UserInput::Text { text: "x".repeat(MAX_USER_INPUT_TEXT_CHARS), @@ -990,6 +1003,7 @@ async fn turn_start_rejects_combined_oversized_text_input() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![ V2UserInput::Text { text: first, @@ -1064,6 +1078,7 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -1136,6 +1151,7 @@ async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -1209,6 +1225,7 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -1261,6 +1278,7 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( let turn_req2 = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Second".to_string(), text_elements: Vec::new(), @@ -1359,6 +1377,7 @@ async fn turn_start_accepts_collaboration_mode_override_v2() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -1448,6 +1467,7 @@ async fn turn_start_uses_thread_feature_overrides_for_request_user_input_tool_de let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -1519,6 +1539,7 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -1600,6 +1621,7 @@ async fn turn_start_change_personality_mid_thread_v2() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -1624,6 +1646,7 @@ async fn turn_start_change_personality_mid_thread_v2() -> Result<()> { let turn_req2 = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello again".to_string(), text_elements: Vec::new(), @@ -1726,6 +1749,7 @@ async fn turn_start_uses_migrated_pragmatic_personality_without_override_v2() -> let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -1846,6 +1870,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { let first_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run python".to_string(), text_elements: Vec::new(), @@ -1910,6 +1935,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { let second_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run python again".to_string(), text_elements: Vec::new(), @@ -1987,6 +2013,7 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run python".to_string(), text_elements: Vec::new(), @@ -2142,6 +2169,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { .send_turn_start_request(TurnStartParams { environments: None, thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "first turn".to_string(), text_elements: Vec::new(), @@ -2185,6 +2213,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { .send_turn_start_request(TurnStartParams { environments: None, thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "second turn".to_string(), text_elements: Vec::new(), @@ -2327,6 +2356,7 @@ stream_max_retries = 0 let first_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "select dev profile".to_string(), text_elements: Vec::new(), @@ -2350,6 +2380,7 @@ stream_max_retries = 0 let second_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "write in new root".to_string(), text_elements: Vec::new(), @@ -2482,6 +2513,7 @@ async fn run_environment_selection_case( let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: format!("run {}", case.name), text_elements: Vec::new(), @@ -2597,6 +2629,7 @@ async fn turn_start_file_change_approval_v2() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "apply patch".into(), text_elements: Vec::new(), @@ -2787,6 +2820,7 @@ async fn turn_start_does_not_stream_apply_patch_change_updates_without_feature_v let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "apply patch".into(), text_elements: Vec::new(), @@ -2924,6 +2958,7 @@ async fn turn_start_streams_apply_patch_change_updates_v2() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "apply patch".into(), text_elements: Vec::new(), @@ -3054,6 +3089,7 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<() let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: PARENT_PROMPT.to_string(), text_elements: Vec::new(), @@ -3273,6 +3309,7 @@ config_file = "./custom-role.toml" let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: PARENT_PROMPT.to_string(), text_elements: Vec::new(), @@ -3418,6 +3455,7 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res let turn_1_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "apply patch 1".into(), text_elements: Vec::new(), @@ -3490,6 +3528,7 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res let turn_2_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "apply patch 2".into(), text_elements: Vec::new(), @@ -3588,6 +3627,7 @@ async fn turn_start_file_change_approval_decline_v2() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "apply patch".into(), text_elements: Vec::new(), @@ -3733,6 +3773,7 @@ async fn command_execution_notifications_include_process_id() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run a command".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 7b7a6c2563..ec77af88dd 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -117,6 +117,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run echo hi".to_string(), text_elements: Vec::new(), @@ -235,6 +236,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run python".to_string(), text_elements: Vec::new(), @@ -367,6 +369,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run python".to_string(), text_elements: Vec::new(), @@ -525,6 +528,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "remove both files".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 66ac33972b..3d4b46b91d 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -12,10 +12,12 @@ use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::AdditionalContextEntry; use codex_app_server_protocol::AdditionalContextKind; +use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; @@ -67,6 +69,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { thread_id: thread.id.clone(), + client_user_message_id: Some("client-steer-message-1".to_string()), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -152,6 +155,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run sleep".to_string(), text_elements: Vec::new(), @@ -177,6 +181,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: oversized_input.clone(), text_elements: Vec::new(), @@ -217,10 +222,10 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let shell_command = vec![ "powershell".to_string(), "-Command".to_string(), - "Start-Sleep -Seconds 10".to_string(), + "Start-Sleep -Seconds 2".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec!["sleep".to_string(), "2".to_string()]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); @@ -228,14 +233,16 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let working_directory = tmp.path().join("workdir"); std::fs::create_dir(&working_directory)?; - let server = - create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_shell_command_sse_response( shell_command.clone(), Some(&working_directory), Some(10_000), "call_sleep", - )?]) - .await; + )?, + app_test_support::create_final_assistant_message_sse_response("Done")?, + ]) + .await; write_mock_responses_config_toml_with_chatgpt_base_url( &codex_home, &server.uri(), @@ -262,6 +269,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run sleep".to_string(), text_elements: Vec::new(), @@ -286,6 +294,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { thread_id: thread.id.clone(), + client_user_message_id: Some("client-steer-message-1".to_string()), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -303,9 +312,38 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let notification = mcp + .read_stream_until_notification_message("item/started") + .await?; + let params = notification.params.expect("item/started params"); + let item_started: ItemStartedNotification = + serde_json::from_value(params).expect("deserialize item/started notification"); + let ThreadItem::UserMessage { + client_id, content, .. + } = item_started.item + else { + continue; + }; + if client_id == Some("client-steer-message-1".to_string()) { + assert_eq!( + content, + vec![V2UserInput::Text { + text: "steer".to_string(), + text_elements: Vec::new(), + }] + ); + return Ok::<(), anyhow::Error>(()); + } + } + }) + .await??; + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["session_id"], thread.session_id); assert_eq!(event["event_params"]["result"], "accepted"); assert_eq!(event["event_params"]["num_input_images"], 0); assert_eq!(event["event_params"]["expected_turn_id"], turn.id); @@ -315,8 +353,11 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { serde_json::Value::Null ); - mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) - .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; Ok(()) } @@ -365,6 +406,7 @@ async fn turn_steer_rejects_context_only_input_without_merging_context() -> Resu let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run sleep".to_string(), text_elements: Vec::new(), @@ -395,6 +437,7 @@ async fn turn_steer_rejects_context_only_input_without_merging_context() -> Resu let steer_req = mcp .send_turn_steer_request(TurnSteerParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: Vec::new(), responsesapi_client_metadata: None, additional_context, diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs index 58b2ff91a8..5cc02c7f6f 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -88,6 +88,7 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Search the web".to_string(), text_elements: Vec::new(), @@ -112,9 +113,12 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> { assert_eq!(requests.len(), 2); let first_response = requests[0].body_json(); - assert!( - requests[0].tool_by_name("web", "run").is_some(), - "web.run should be sent to the model" + let web_run = requests[0] + .tool_by_name("web", "run") + .context("web.run should be sent to the model")?; + assert_eq!( + web_run.pointer("/parameters/properties/time/description"), + Some(&json!("Get time for the given UTC offsets.")) ); assert!( !has_hosted_web_search(&first_response), diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index aa2326ab28..4e79141aab 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -776,7 +776,7 @@ fn compute_replacements( } } - replacements.sort_by(|(lhs_idx, _, _), (rhs_idx, _, _)| lhs_idx.cmp(rhs_idx)); + replacements.sort_by_key(|(index, _, _)| *index); Ok(replacements) } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 1a1b852667..6a4d78fbc7 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -83,6 +83,7 @@ tracing = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } unicode-segmentation = { workspace = true } +url = { workspace = true } which = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 918d81aad2..36c5e683b2 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -209,9 +209,9 @@ async fn run_command_under_sandbox( // does not support `--cwd`, but let's use the config value for consistency. let cwd = config.cwd.clone(); // For now, we always use the same cwd for both the command and the - // sandbox policy. In the future, we could add a CLI option to set them + // permission profile. In the future, we could add a CLI option to set them // separately. - let sandbox_policy_cwd = cwd.clone(); + let permission_profile_cwd = cwd.clone(); let env = create_env( &config.permissions.shell_environment_policy, @@ -222,7 +222,8 @@ async fn run_command_under_sandbox( if let SandboxType::Windows = sandbox_type { #[cfg(target_os = "windows")] { - run_command_under_windows_session(&config, command, cwd, sandbox_policy_cwd, env).await; + run_command_under_windows_session(&config, command, cwd, permission_profile_cwd, env) + .await; } #[cfg(not(target_os = "windows"))] { @@ -265,7 +266,7 @@ async fn run_command_under_sandbox( command, file_system_sandbox_policy: &file_system_sandbox_policy, network_sandbox_policy, - sandbox_policy_cwd: sandbox_policy_cwd.as_path(), + sandbox_policy_cwd: permission_profile_cwd.as_path(), enforce_managed_network: false, network: network.as_ref(), extra_allow_unix_sockets: allow_unix_sockets, @@ -297,7 +298,7 @@ async fn run_command_under_sandbox( command, cwd.as_path(), &config.permissions.effective_permission_profile(), - sandbox_policy_cwd.as_path(), + permission_profile_cwd.as_path(), use_legacy_landlock, allow_network_for_proxy(managed_network_requirements_enabled), ); @@ -349,24 +350,15 @@ async fn run_command_under_windows_session( config: &Config, command: Vec, cwd: AbsolutePathBuf, - sandbox_policy_cwd: AbsolutePathBuf, + permission_profile_cwd: AbsolutePathBuf, env: std::collections::HashMap, ) -> ! { use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_protocol::config_types::WindowsSandboxLevel; - use codex_windows_sandbox::spawn_windows_sandbox_session_elevated; + use codex_windows_sandbox::spawn_windows_sandbox_session_elevated_for_permission_profile; use codex_windows_sandbox::spawn_windows_sandbox_session_legacy; - let sandbox_policy = config - .permissions - .legacy_sandbox_policy(sandbox_policy_cwd.as_path()); - let policy_str = match serde_json::to_string(&sandbox_policy) { - Ok(policy_str) => policy_str, - Err(err) => { - eprintln!("windows sandbox failed to serialize policy: {err}"); - std::process::exit(1); - } - }; + let permission_profile = config.permissions.effective_permission_profile(); let use_elevated = matches!( WindowsSandboxLevel::from_config(config), @@ -374,9 +366,9 @@ async fn run_command_under_windows_session( ); let spawned = if use_elevated { - spawn_windows_sandbox_session_elevated( - policy_str.as_str(), - sandbox_policy_cwd.as_path(), + spawn_windows_sandbox_session_elevated_for_permission_profile( + &permission_profile, + permission_profile_cwd.as_path(), config.codex_home.as_path(), command, cwd.as_path(), @@ -394,8 +386,8 @@ async fn run_command_under_windows_session( .await } else { spawn_windows_sandbox_session_legacy( - policy_str.as_str(), - sandbox_policy_cwd.as_path(), + &permission_profile, + permission_profile_cwd.as_path(), config.codex_home.as_path(), command, cwd.as_path(), diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index d2422d05de..2e53d5d51f 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -2176,11 +2176,7 @@ struct RolloutStats { impl RolloutStats { fn average_bytes(&self) -> u64 { - if self.files == 0 { - 0 - } else { - self.total_bytes / self.files - } + self.total_bytes.checked_div(self.files).unwrap_or(0) } } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 856273354c..8dfe6dc33f 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -452,6 +452,10 @@ struct AppServerCommand { )] listen: codex_app_server::AppServerTransport, + /// Use stdio as the transport (equivalent to `--listen stdio://`). + #[arg(long = "stdio", conflicts_with = "listen")] + stdio: bool, + /// Enable remote control for this app-server process. #[arg(long = "remote-control", hide = true)] remote_control: bool, @@ -965,6 +969,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { subcommand, strict_config: app_server_strict_config, listen, + stdio, remote_control, analytics_default_enabled, auth, @@ -978,7 +983,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; match subcommand { None => { - let transport = listen; + let transport = if stdio { + codex_app_server::AppServerTransport::Stdio + } else { + listen + }; let auth = auth.try_into_settings()?; let runtime_options = codex_app_server::AppServerRuntimeOptions { remote_control_enabled: remote_control, @@ -1486,7 +1495,8 @@ async fn run_exec_server_command( .ok_or_else(|| anyhow::anyhow!("--environment-id is required when --remote is set"))?; let config = load_exec_server_config(root_config_overrides, strict_config).await?; let auth_provider = - load_exec_server_remote_auth_provider(&config, cmd.use_agent_identity_auth).await?; + load_exec_server_remote_auth_provider(&config, &base_url, cmd.use_agent_identity_auth) + .await?; let mut remote_config = codex_exec_server::RemoteEnvironmentConfig::new( base_url, environment_id, @@ -1516,6 +1526,7 @@ async fn run_exec_server_command( async fn load_exec_server_remote_auth_provider( config: &codex_core::config::Config, + base_url: &str, use_agent_identity_auth: bool, ) -> anyhow::Result { if use_agent_identity_auth { @@ -1530,19 +1541,61 @@ async fn load_exec_server_remote_auth_provider( let auth = load_exec_server_remote_auth( config, - "remote exec-server registration requires ChatGPT authentication; run `codex login` first", + "remote exec-server registration requires ChatGPT authentication or API key authentication; run `codex login` or set CODEX_API_KEY", ) .await?; - if !auth.is_chatgpt_auth() { + if !is_supported_exec_server_remote_auth(&auth) { anyhow::bail!( - "remote exec-server registration requires ChatGPT authentication; API key and Agent Identity auth are not supported" + "remote exec-server registration requires ChatGPT authentication or API key authentication; Agent Identity auth requires --use-agent-identity-auth" ); } + if auth.is_api_key_auth() { + validate_api_key_remote_host(base_url)?; + } + Ok(codex_model_provider::auth_provider_from_auth(&auth)) } +fn is_supported_exec_server_remote_auth(auth: &CodexAuth) -> bool { + auth.is_chatgpt_auth() || auth.is_api_key_auth() +} + +fn validate_api_key_remote_host(base_url: &str) -> anyhow::Result<()> { + let url = url::Url::parse(base_url) + .map_err(|err| anyhow::anyhow!("invalid remote exec-server registration URL: {err}"))?; + let host = url.host().ok_or_else(|| { + anyhow::anyhow!("remote exec-server registration URL must include a host") + })?; + + let is_loopback = match &host { + url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"), + url::Host::Ipv4(ip) => ip.is_loopback(), + url::Host::Ipv6(ip) => ip.is_loopback(), + }; + let is_openai_host = match &host { + url::Host::Domain(host) => ["openai.com", "openai.org"].into_iter().any(|domain| { + host.eq_ignore_ascii_case(domain) + || host.to_ascii_lowercase().ends_with(&format!(".{domain}")) + }), + _ => false, + }; + let is_allowed = match url.scheme() { + "https" => is_loopback || is_openai_host, + "http" => is_loopback, + _ => false, + }; + + if !is_allowed { + anyhow::bail!( + "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" + ); + } + + Ok(()) +} + async fn load_exec_server_config( root_config_overrides: &CliConfigOverrides, strict_config: bool, @@ -2168,6 +2221,63 @@ mod tests { use codex_tui::TokenUsage; use pretty_assertions::assert_eq; + #[test] + fn exec_server_remote_auth_accepts_api_key_auth() { + let auth = CodexAuth::from_api_key("sk-test"); + + assert!(is_supported_exec_server_remote_auth(&auth)); + } + + #[test] + fn exec_server_remote_api_key_auth_accepts_https_openai_domains() { + for base_url in [ + "https://openai.com/api", + "https://service.openai.com/api", + "https://openai.org/api", + "https://service.openai.org/api", + ] { + assert!(validate_api_key_remote_host(base_url).is_ok()); + } + } + + #[test] + fn exec_server_remote_api_key_auth_accepts_http_loopback() { + for base_url in [ + "http://localhost:8098/api", + "http://127.0.0.1:8098/api", + "http://[::1]:8098/api", + ] { + assert!(validate_api_key_remote_host(base_url).is_ok()); + } + } + + #[test] + fn exec_server_remote_api_key_auth_rejects_http_openai_domain() { + for base_url in [ + "http://service.openai.com/api", + "http://service.openai.org/api", + ] { + let error = validate_api_key_remote_host(base_url) + .expect_err("reject plaintext OpenAI destination"); + + assert_eq!( + error.to_string(), + "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" + ); + } + } + + #[test] + fn exec_server_remote_api_key_auth_rejects_suffix_spoof() { + let error = validate_api_key_remote_host("https://service.openai.org.evil.example/api") + .expect_err("reject suffix spoof"); + + assert_eq!( + error.to_string(), + "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" + ); + } + fn finalize_resume_from_args(args: &[&str]) -> TuiCli { let cli = MultitoolCli::try_parse_from(args).expect("parse"); let MultitoolCli { @@ -3096,6 +3206,25 @@ mod tests { ); } + #[test] + fn app_server_stdio_flag_parses() { + let app_server = app_server_from_args(["codex", "app-server", "--stdio"].as_ref()); + assert!(app_server.stdio); + } + + #[test] + fn app_server_stdio_flag_conflicts_with_listen() { + let err = MultitoolCli::try_parse_from([ + "codex", + "app-server", + "--stdio", + "--listen", + "stdio://", + ]) + .expect_err("--stdio and --listen should be rejected together"); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + #[test] fn app_server_listen_unix_socket_url_parses() { let app_server = diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index a84d1c9019..0103782653 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -537,7 +537,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> let effective_mcp_servers = mcp_manager.effective_servers(&config, /*auth*/ None).await; let mut entries: Vec<_> = mcp_servers.iter().collect(); - entries.sort_by(|(a, _), (b, _)| a.cmp(b)); + entries.sort_by_key(|(name, _)| *name); let auth_statuses = compute_auth_statuses( effective_mcp_servers.iter(), config.mcp_oauth_credentials_store_mode, @@ -906,7 +906,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re let headers_display = match http_headers { Some(map) if !map.is_empty() => { let mut pairs: Vec<_> = map.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(name, _)| *name); pairs .into_iter() .map(|(k, _)| format!("{k}=*****")) @@ -919,7 +919,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re let env_headers_display = match env_http_headers { Some(map) if !map.is_empty() => { let mut pairs: Vec<_> = map.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(name, _)| *name); pairs .into_iter() .map(|(k, var)| format!("{k}={var}")) diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index 18d3c4555c..1362b51a4c 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -24,7 +24,7 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co - Global helpers: - `exit()`: Immediately ends the current script successfully (like an early return from the top level). - `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible. -- `image(imageUrlOrItem: string | { image_url: string; detail?: "high" | "original" | null } | ImageContent, detail?: "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument. +- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null } | ImageContent, detail?: "auto" | "low" | "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument. - `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session. - `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing. - `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`. @@ -649,7 +649,7 @@ fn render_json_schema_object(map: &serde_json::Map) -> String .unwrap_or_default(); let mut sorted_properties = properties.iter().collect::>(); - sorted_properties.sort_unstable_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b)); + sorted_properties.sort_unstable_by_key(|(name_a, _)| *name_a); if sorted_properties .iter() .any(|(_, value)| has_property_description(value)) diff --git a/codex-rs/code-mode/src/response.rs b/codex-rs/code-mode/src/response.rs index ae92639cc0..0ac3a03770 100644 --- a/codex-rs/code-mode/src/response.rs +++ b/codex-rs/code-mode/src/response.rs @@ -4,6 +4,8 @@ use serde::Serialize; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ImageDetail { + Auto, + Low, High, Original, } diff --git a/codex-rs/code-mode/src/runtime/value.rs b/codex-rs/code-mode/src/runtime/value.rs index 865b5a569d..8d76a832d3 100644 --- a/codex-rs/code-mode/src/runtime/value.rs +++ b/codex-rs/code-mode/src/runtime/value.rs @@ -71,10 +71,14 @@ pub(super) fn normalize_output_image( Some(detail) => { let normalized = detail.to_ascii_lowercase(); Some(match normalized.as_str() { + "auto" => ImageDetail::Auto, + "low" => ImageDetail::Low, "high" => ImageDetail::High, "original" => ImageDetail::Original, _ => { - return Err("image detail must be one of: high, original".to_string()); + return Err( + "image detail must be one of: auto, low, high, original".to_string() + ); } }) } @@ -156,7 +160,7 @@ fn parse_mcp_output_image( .and_then(JsonValue::as_object) .and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY)) .and_then(JsonValue::as_str) - .filter(|detail| matches!(*detail, "high" | "original")) + .filter(|detail| matches!(*detail, "auto" | "low" | "high" | "original")) .map(str::to_string); Ok((image_url, detail)) } diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index de4ed13e58..d153f1b416 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -1359,7 +1359,7 @@ image( } #[tokio::test] - async fn image_helper_rejects_unsupported_detail() { + async fn image_helper_accepts_low_detail() { let service = CodeModeService::new(); let response = service @@ -1369,6 +1369,38 @@ image({ image_url: "https://example.com/image.jpg", detail: "low", }); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputImage { + image_url: "https://example.com/image.jpg".to_string(), + detail: Some(crate::ImageDetail::Low), + }], + error_text: None, + } + ); + } + + #[tokio::test] + async fn image_helper_rejects_unsupported_detail() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#" +image({ + image_url: "https://example.com/image.jpg", + detail: "medium", +}); "# .to_string(), yield_time_ms: None, @@ -1382,7 +1414,9 @@ image({ RuntimeResponse::Result { cell_id: "1".to_string(), content_items: Vec::new(), - error_text: Some("image detail must be one of: high, original".to_string()), + error_text: Some( + "image detail must be one of: auto, low, high, original".to_string() + ), } ); } diff --git a/codex-rs/codex-client/src/custom_ca.rs b/codex-rs/codex-client/src/custom_ca.rs index 7a8b2f27bd..1a211beedf 100644 --- a/codex-rs/codex-client/src/custom_ca.rs +++ b/codex-rs/codex-client/src/custom_ca.rs @@ -477,15 +477,13 @@ impl ConfiguredCaBundle { })?; certificates.push(CertificateDer::from(cert_der.to_vec())); } - SectionKind::Crl => { - if !logged_crl_presence { - info!( - source_env = self.source_env, - ca_path = %self.path.display(), - "ignoring X509 CRL entries found in custom CA bundle" - ); - logged_crl_presence = true; - } + SectionKind::Crl if !logged_crl_presence => { + info!( + source_env = self.source_env, + ca_path = %self.path.display(), + "ignoring X509 CRL entries found in custom CA bundle" + ); + logged_crl_presence = true; } _ => {} } diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index eb88ffa6b1..3196a143e8 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -12,7 +12,9 @@ use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::runtime::emit_duration; use crate::tools::MCP_TOOLS_CACHE_WRITE_DURATION_METRIC; use crate::tools::ToolInfo; +use anyhow::Context; use codex_login::CodexAuth; +use codex_protocol::mcp::McpServerInfo; use codex_utils_plugins::mcp_connector::is_connector_id_allowed; use codex_utils_plugins::mcp_connector::sanitize_name; use serde::Deserialize; @@ -20,8 +22,6 @@ use serde::Serialize; use sha1::Digest; use sha1::Sha1; -pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 3; - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CodexAppsToolsCacheKey { pub(crate) account_id: Option, @@ -44,11 +44,19 @@ pub(crate) struct CodexAppsToolsCacheContext { } impl CodexAppsToolsCacheContext { - pub(crate) fn cache_path(&self) -> PathBuf { + pub(crate) fn tools_cache_path(&self) -> PathBuf { + self.cache_path_in(CODEX_APPS_TOOLS_CACHE_DIR) + } + + pub(crate) fn server_info_cache_path(&self) -> PathBuf { + self.cache_path_in(CODEX_APPS_SERVER_INFO_CACHE_DIR) + } + + fn cache_path_in(&self, cache_dir: &str) -> PathBuf { let user_key_json = serde_json::to_string(&self.user_key).unwrap_or_default(); let user_key_hash = sha1_hex(&user_key_json); self.codex_home - .join(CODEX_APPS_TOOLS_CACHE_DIR) + .join(cache_dir) .join(format!("{user_key_hash}.json")) } } @@ -136,6 +144,7 @@ pub(crate) fn normalize_codex_apps_callable_namespace( pub(crate) fn write_cached_codex_apps_tools_if_needed( server_name: &str, cache_context: Option<&CodexAppsToolsCacheContext>, + server_info: &McpServerInfo, tools: &[ToolInfo], ) { if server_name != CODEX_APPS_MCP_SERVER_NAME { @@ -145,6 +154,9 @@ pub(crate) fn write_cached_codex_apps_tools_if_needed( if let Some(cache_context) = cache_context { let cache_write_start = Instant::now(); write_cached_codex_apps_tools(cache_context, tools); + if let Err(err) = write_cached_codex_apps_server_info(cache_context, server_info) { + tracing::warn!("failed to write Codex Apps server info cache: {err:#}"); + } emit_duration( MCP_TOOLS_CACHE_WRITE_DURATION_METRIC, cache_write_start.elapsed(), @@ -169,6 +181,17 @@ pub(crate) fn load_startup_cached_codex_apps_tools_snapshot( } } +pub(crate) fn load_startup_cached_codex_apps_server_info( + server_name: &str, + cache_context: Option<&CodexAppsToolsCacheContext>, +) -> Option { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return None; + } + + load_cached_codex_apps_server_info(cache_context?) +} + #[cfg(test)] pub(crate) fn read_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, @@ -182,7 +205,7 @@ pub(crate) fn read_cached_codex_apps_tools( pub(crate) fn load_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, ) -> CachedCodexAppsToolsLoad { - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); let bytes = match std::fs::read(cache_path) { Ok(bytes) => bytes, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { @@ -204,7 +227,7 @@ pub(crate) fn write_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, tools: &[ToolInfo], ) { - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); if let Some(parent) = cache_path.parent() && std::fs::create_dir_all(parent).is_err() { @@ -220,6 +243,42 @@ pub(crate) fn write_cached_codex_apps_tools( let _ = std::fs::write(cache_path, bytes); } +pub(crate) fn load_cached_codex_apps_server_info( + cache_context: &CodexAppsToolsCacheContext, +) -> Option { + let bytes = std::fs::read(cache_context.server_info_cache_path()).ok()?; + let cache: CodexAppsServerInfoDiskCache = serde_json::from_slice(&bytes).ok()?; + (cache.schema_version == CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION) + .then_some(cache.server_info) +} + +fn write_cached_codex_apps_server_info( + cache_context: &CodexAppsToolsCacheContext, + server_info: &McpServerInfo, +) -> anyhow::Result<()> { + let cache_path = cache_context.server_info_cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create Codex Apps server info cache directory `{}`", + parent.display() + ) + })?; + } + let bytes = serde_json::to_vec_pretty(&CodexAppsServerInfoDiskCache { + schema_version: CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION, + server_info: server_info.clone(), + }) + .context("failed to serialize Codex Apps server info cache")?; + std::fs::write(&cache_path, bytes).with_context(|| { + format!( + "failed to write Codex Apps server info cache `{}`", + cache_path.display() + ) + })?; + Ok(()) +} + pub(crate) fn filter_disallowed_codex_apps_tools(tools: Vec) -> Vec { tools .into_iter() @@ -237,7 +296,17 @@ struct CodexAppsToolsDiskCache { tools: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CodexAppsServerInfoDiskCache { + schema_version: u8, + server_info: McpServerInfo, +} + const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools"; +pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 3; + +const CODEX_APPS_SERVER_INFO_CACHE_DIR: &str = "cache/codex_apps_server_info"; +const CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION: u8 = 1; fn sha1_hex(s: &str) -> String { let mut hasher = Sha1::new(); diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 91ed8b1b64..d15a2e1500 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -9,6 +9,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; @@ -44,6 +45,7 @@ use codex_config::McpServerTransportConfig; use codex_config::types::OAuthCredentialsStoreMode; use codex_login::CodexAuth; use codex_protocol::mcp::CallToolResult; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; @@ -62,11 +64,43 @@ use rmcp::model::ReadResourceResult; use rmcp::model::RequestId; use rmcp::model::Resource; use rmcp::model::ResourceTemplate; +use serde_json::Value as JsonValue; use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; +use tracing::Instrument; use tracing::instrument; +use tracing::trace; +use tracing::trace_span; use tracing::warn; +const MCP_UI_META_KEY: &str = "ui"; +const MCP_UI_VISIBILITY_META_KEY: &str = "visibility"; +const MCP_UI_MODEL_VISIBILITY: &str = "model"; + +/// Returns whether a tool may be included in model-facing tool declarations. +/// +/// Tools without visibility metadata remain visible. +/// Tools with visibility metadata are hidden unless they explicitly include `model`. +/// +/// +pub fn tool_is_model_visible(tool: &ToolInfo) -> bool { + let Some(visibility) = tool + .tool + .meta + .as_deref() + .and_then(|meta| meta.get(MCP_UI_META_KEY)) + .and_then(JsonValue::as_object) + .and_then(|ui| ui.get(MCP_UI_VISIBILITY_META_KEY)) + .and_then(JsonValue::as_array) + else { + return true; + }; + + visibility + .iter() + .any(|target| target.as_str() == Some(MCP_UI_MODEL_VISIBILITY)) +} + /// A thin wrapper around a set of running [`RmcpClient`] instances. pub struct McpConnectionManager { clients: HashMap, @@ -378,13 +412,37 @@ impl McpConnectionManager { } /// Returns all tools with model-visible names normalized. - #[instrument(level = "trace", skip_all)] + #[instrument(level = "trace", skip_all, fields(mcp_server_count = self.clients.len()))] pub async fn list_all_tools(&self) -> Vec { let mut tools = Vec::new(); - for managed_client in self.clients.values() { - let Some(server_tools) = managed_client.listed_tools().await else { + for (server_name, managed_client) in &self.clients { + let has_cached_tool_info_snapshot = managed_client.cached_tool_info_snapshot.is_some(); + let startup_complete = managed_client + .startup_complete + .load(std::sync::atomic::Ordering::Acquire); + trace!( + server_name = %server_name, + has_cached_tool_info_snapshot, + startup_complete, + "waiting for MCP server tools while building tool list" + ); + let Some(server_tools) = managed_client + .listed_tools() + .instrument(trace_span!( + "list_tools_for_server", + server_name = %server_name, + has_cached_tool_info_snapshot, + startup_complete + )) + .await + else { continue; }; + trace!( + server_name = %server_name, + tool_count = server_tools.len(), + "listed MCP server tools while building tool list" + ); tools.extend( server_tools .into_iter() @@ -394,6 +452,31 @@ impl McpConnectionManager { normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names) } + /// Returns presentation metadata without waiting for uncached clients still initializing. + /// Cached values will be used if available and the server is still starting up. + pub async fn list_available_server_infos(&self) -> HashMap { + let mut server_infos = HashMap::new(); + for (server_name, client) in &self.clients { + if !client.startup_complete.load(Ordering::Acquire) { + if let Some(server_info) = client.cached_server_info.clone() { + server_infos.insert(server_name.clone(), server_info); + } + continue; + } + match client.client().await { + Ok(managed_client) => { + server_infos.insert(server_name.clone(), managed_client.server_info); + } + Err(_) => { + if let Some(server_info) = client.cached_server_info.clone() { + server_infos.insert(server_name.clone(), server_info); + } + } + } + } + server_infos + } + /// Force-refresh codex apps tools by bypassing the in-process cache. /// /// On success, the refreshed tools replace the cache contents and the @@ -429,6 +512,7 @@ impl McpConnectionManager { write_cached_codex_apps_tools_if_needed( CODEX_APPS_MCP_SERVER_NAME, managed_client.codex_apps_tools_cache_context.as_ref(), + &managed_client.server_info, &tools, ); emit_duration( @@ -483,9 +567,8 @@ impl McpConnectionManager { let mut cursor: Option = None; loop { - let params = cursor.as_ref().map(|next| PaginatedRequestParams { - meta: None, - cursor: Some(next.clone()), + let params = cursor.as_ref().map(|next| { + PaginatedRequestParams::default().with_cursor(Some(next.clone())) }); let response = match client.list_resources(params, timeout).await { Ok(result) => result, @@ -549,9 +632,8 @@ impl McpConnectionManager { let mut cursor: Option = None; loop { - let params = cursor.as_ref().map(|next| PaginatedRequestParams { - meta: None, - cursor: Some(next.clone()), + let params = cursor.as_ref().map(|next| { + PaginatedRequestParams::default().with_cursor(Some(next.clone())) }); let response = match client.list_resource_templates(params, timeout).await { Ok(result) => result, diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 86eb703e7a..60a0026eeb 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1,9 +1,11 @@ use super::*; use crate::codex_apps::CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION; use crate::codex_apps::CodexAppsToolsCacheContext; +use crate::codex_apps::load_startup_cached_codex_apps_server_info; use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; use crate::codex_apps::read_cached_codex_apps_tools; use crate::codex_apps::write_cached_codex_apps_tools; +use crate::codex_apps::write_cached_codex_apps_tools_if_needed; use crate::declared_openai_file_input_param_names; use crate::elicitation::ElicitationRequestManager; use crate::elicitation::elicitation_is_rejected_by_policy; @@ -20,6 +22,7 @@ use codex_config::Constrained; use codex_config::McpServerConfig; use codex_exec_server::EnvironmentManager; use codex_protocol::ToolName; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::McpAuthStatus; @@ -44,17 +47,11 @@ fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { callable_name: tool_name.to_string(), callable_namespace: server_name.to_string(), namespace_description: None, - tool: Tool { - name: tool_name.to_string().into(), - title: None, - description: Some(format!("Test tool: {tool_name}").into()), - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + tool: Tool::new( + tool_name.to_string(), + format!("Test tool: {tool_name}"), + Arc::new(JsonObject::default()), + ), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), @@ -88,6 +85,17 @@ fn create_codex_apps_tools_cache_context( } } +fn create_test_server_info(title: &str) -> McpServerInfo { + McpServerInfo { + name: "codex-apps".to_string(), + title: Some(title.to_string()), + version: "1.0.0".to_string(), + description: None, + icons: None, + website_url: None, + } +} + fn model_tool_names(tools: &[ToolInfo]) -> HashSet { tools .iter() @@ -588,8 +596,8 @@ fn codex_apps_tools_cache_is_scoped_per_user() { assert_eq!(read_user_1[0].callable_name, "one"); assert_eq!(read_user_2[0].callable_name, "two"); assert_ne!( - cache_context_user_1.cache_path(), - cache_context_user_2.cache_path(), + cache_context_user_1.tools_cache_path(), + cache_context_user_2.tools_cache_path(), "each user should get an isolated cache file" ); } @@ -633,7 +641,7 @@ fn codex_apps_tools_cache_is_ignored_when_schema_version_mismatches() { Some("account-one"), Some("user-one"), ); - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); if let Some(parent) = cache_path.parent() { std::fs::create_dir_all(parent).expect("create parent"); } @@ -655,7 +663,7 @@ fn codex_apps_tools_cache_is_ignored_when_json_is_invalid() { Some("account-one"), Some("user-one"), ); - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); if let Some(parent) = cache_path.parent() { std::fs::create_dir_all(parent).expect("create parent"); } @@ -676,21 +684,112 @@ fn startup_cached_codex_apps_tools_loads_from_disk_cache() { CODEX_APPS_MCP_SERVER_NAME, "calendar_search", )]; - write_cached_codex_apps_tools(&cache_context, &cached_tools); + let server_info = create_test_server_info("Codex Apps"); + write_cached_codex_apps_tools_if_needed( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + &server_info, + &cached_tools, + ); - let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( + let startup_tools = load_startup_cached_codex_apps_tools_snapshot( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ) + .expect("expected startup snapshot to load from cache"); + let cached_server_info = load_startup_cached_codex_apps_server_info( CODEX_APPS_MCP_SERVER_NAME, Some(&cache_context), ); - let startup_tools = startup_snapshot.expect("expected startup snapshot to load from cache"); assert_eq!(startup_tools.len(), 1); assert_eq!(startup_tools[0].server_name, CODEX_APPS_MCP_SERVER_NAME); assert_eq!(startup_tools[0].callable_name, "calendar_search"); + assert_eq!(cached_server_info, Some(server_info)); +} + +#[test] +fn startup_cached_codex_apps_tools_loads_without_server_info_cache() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cache_path = cache_context.tools_cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + let bytes = serde_json::to_vec_pretty(&serde_json::json!({ + "schema_version": CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION, + "tools": [create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "calendar_search")], + })) + .expect("serialize"); + std::fs::write(cache_path, bytes).expect("write"); + + let startup_tools = load_startup_cached_codex_apps_tools_snapshot( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ) + .expect("legacy startup snapshot should remain available"); + let cached_server_info = load_startup_cached_codex_apps_server_info( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ); + + assert_eq!(startup_tools.len(), 1); + assert_eq!(startup_tools[0].callable_name, "calendar_search"); + assert_eq!(cached_server_info, None); +} + +#[test] +fn codex_apps_server_info_cache_survives_legacy_tools_cache_write() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let server_info = create_test_server_info("Codex Apps"); + write_cached_codex_apps_tools_if_needed( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + &server_info, + &[create_test_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_search", + )], + ); + + let cache_path = cache_context.tools_cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + let bytes = serde_json::to_vec_pretty(&serde_json::json!({ + "schema_version": CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION - 1, + "tools": [create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "calendar_search")], + })) + .expect("serialize"); + std::fs::write(cache_path, bytes).expect("write legacy tools cache"); + + assert_eq!( + load_startup_cached_codex_apps_server_info( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ), + Some(server_info) + ); + assert!( + load_startup_cached_codex_apps_tools_snapshot( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ) + .is_none() + ); } #[tokio::test] -async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { +async fn list_all_tools_uses_cached_tool_info_snapshot_while_client_is_pending() { let startup_tools = vec![create_test_tool( CODEX_APPS_MCP_SERVER_NAME, "calendar_create_event", @@ -709,7 +808,8 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(startup_tools), + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -728,6 +828,43 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { assert_eq!(tool.callable_name, "calendar_create_event"); } +#[tokio::test] +async fn list_available_server_infos_uses_cache_while_client_is_pending() { + let pending_client = futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ true, + ); + let server_info = create_test_server_info("Codex Apps"); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: pending_client, + cached_tool_info_snapshot: Some(Vec::new()), + cached_server_info: Some(server_info.clone()), + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + cancel_token: CancellationToken::new(), + }, + ); + + let timeout_result = tokio::time::timeout( + Duration::from_millis(10), + manager.list_available_server_infos(), + ) + .await; + let server_infos = timeout_result.expect("server info lookup should not block on startup"); + assert_eq!( + server_infos.get(CODEX_APPS_MCP_SERVER_NAME), + Some(&server_info) + ); +} + #[tokio::test] async fn list_all_tools_accepts_canonical_namespaced_tool_names() { let startup_tools = vec![create_test_tool("rmcp", "echo")]; @@ -745,7 +882,8 @@ async fn list_all_tools_accepts_canonical_namespaced_tool_names() { "rmcp".to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(startup_tools), + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -787,7 +925,8 @@ async fn list_all_tools_applies_legacy_mcp_prefix_by_default() { "rmcp".to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(startup_tools), + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -813,7 +952,7 @@ async fn list_all_tools_applies_legacy_mcp_prefix_by_default() { } #[tokio::test] -async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot() { +async fn list_all_tools_blocks_while_client_is_pending_without_cached_tool_info_snapshot() { let pending_client = futures::future::pending::>() .boxed() .shared(); @@ -828,7 +967,8 @@ async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: None, + cached_tool_info_snapshot: None, + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -841,7 +981,7 @@ async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot( } #[tokio::test] -async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty() { +async fn list_all_tools_does_not_block_when_cached_tool_info_snapshot_is_empty() { let pending_client = futures::future::pending::>() .boxed() .shared(); @@ -856,7 +996,8 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(Vec::new()), + cached_tool_info_snapshot: Some(Vec::new()), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -870,11 +1011,12 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty( } #[tokio::test] -async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { +async fn list_all_tools_uses_cached_tool_info_snapshot_when_client_startup_fails() { let startup_tools = vec![create_test_tool( CODEX_APPS_MCP_SERVER_NAME, "calendar_create_event", )]; + let server_info = create_test_server_info("Codex Apps"); let failed_client = futures::future::ready::>(Err( StartupOutcomeError::Failed { error: "startup failed".to_string(), @@ -894,7 +1036,8 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: failed_client, - startup_snapshot: Some(startup_tools), + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: Some(server_info.clone()), startup_complete, tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -911,6 +1054,13 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { .expect("tool from startup cache"); assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME); assert_eq!(tool.callable_name, "calendar_create_event"); + assert_eq!( + manager + .list_available_server_infos() + .await + .get(CODEX_APPS_MCP_SERVER_NAME), + Some(&server_info) + ); } #[tokio::test] @@ -941,7 +1091,8 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() { server_name.to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(startup_tools), + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 7b45bcc7a7..83419175d4 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -1,4 +1,5 @@ pub use connection_manager::McpConnectionManager; +pub use connection_manager::tool_is_model_visible; pub use elicitation::ElicitationReviewRequest; pub use elicitation::ElicitationReviewer; pub use elicitation::ElicitationReviewerHandle; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 407ece427e..51a6f18686 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -25,6 +25,7 @@ use codex_config::types::ApprovalsReviewer; use codex_config::types::OAuthCredentialsStoreMode; use codex_login::CodexAuth; use codex_plugin::PluginCapabilitySummary; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::mcp::Resource; use codex_protocol::mcp::ResourceTemplate; use codex_protocol::mcp::Tool; @@ -300,13 +301,7 @@ pub async fn read_mcp_resource( .await; let result = manager - .read_resource( - server, - ReadResourceRequestParams { - meta: None, - uri: uri.to_string(), - }, - ) + .read_resource(server, ReadResourceRequestParams::new(uri)) .await; cancel_token.cancel(); result @@ -314,6 +309,7 @@ pub async fn read_mcp_resource( #[derive(Debug, Clone)] pub struct McpServerStatusSnapshot { + pub server_infos: HashMap, pub tools_by_server: HashMap>, pub resources: HashMap>, pub resource_templates: HashMap>, @@ -333,6 +329,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( let tool_plugin_provenance = tool_plugin_provenance(config); if mcp_servers.is_empty() { return McpServerStatusSnapshot { + server_infos: HashMap::new(), tools_by_server: HashMap::new(), resources: HashMap::new(), resource_templates: HashMap::new(), @@ -605,6 +602,7 @@ async fn collect_mcp_server_status_snapshot_from_manager( } }, ); + let server_infos = mcp_connection_manager.list_available_server_infos().await; let mut tools_by_server = HashMap::>::new(); for tool_info in tools { @@ -620,6 +618,7 @@ async fn collect_mcp_server_status_snapshot_from_manager( } McpServerStatusSnapshot { + server_infos, tools_by_server, resources: convert_mcp_resources(resources), resource_templates: convert_mcp_resource_templates(resource_templates), diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index c75895842b..78078720e7 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -20,6 +20,7 @@ use crate::codex_apps::CachedCodexAppsToolsLoad; use crate::codex_apps::CodexAppsToolsCacheContext; use crate::codex_apps::filter_disallowed_codex_apps_tools; use crate::codex_apps::load_cached_codex_apps_tools; +use crate::codex_apps::load_startup_cached_codex_apps_server_info; use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; use crate::codex_apps::normalize_codex_apps_callable_name; use crate::codex_apps::normalize_codex_apps_callable_namespace; @@ -47,6 +48,7 @@ use codex_config::McpServerTransportConfig; use codex_config::types::OAuthCredentialsStoreMode; use codex_exec_server::HttpClient; use codex_exec_server::ReqwestHttpClient; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::protocol::Event; use codex_rmcp_client::ExecutorStdioServerLauncher; use codex_rmcp_client::LocalStdioServerLauncher; @@ -85,6 +87,7 @@ const UNTRUSTED_CONNECTOR_META_KEYS: &[&str] = &[ #[derive(Clone)] pub(crate) struct ManagedClient { pub(crate) client: Arc, + pub(crate) server_info: McpServerInfo, pub(crate) tools: Vec, pub(crate) tool_filter: ToolFilter, pub(crate) tool_timeout: Option, @@ -123,7 +126,8 @@ impl ManagedClient { #[derive(Clone)] pub(crate) struct AsyncManagedClient { pub(crate) client: Shared>>, - pub(crate) startup_snapshot: Option>, + pub(crate) cached_tool_info_snapshot: Option>, + pub(crate) cached_server_info: Option, pub(crate) startup_complete: Arc, pub(crate) tool_plugin_provenance: Arc, pub(crate) cancel_token: CancellationToken, @@ -150,11 +154,16 @@ impl AsyncManagedClient { .configured_config() .map(ToolFilter::from_config) .unwrap_or_default(); - let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( + let cached_tool_info_snapshot = load_startup_cached_codex_apps_tools_snapshot( &server_name, codex_apps_tools_cache_context.as_ref(), - ) - .map(|tools| filter_tools(tools, &tool_filter)); + ); + let cached_tool_info_snapshot = + cached_tool_info_snapshot.map(|tools| filter_tools(tools, &tool_filter)); + let cached_server_info = load_startup_cached_codex_apps_server_info( + &server_name, + codex_apps_tools_cache_context.as_ref(), + ); let startup_tool_filter = tool_filter; let startup_complete = Arc::new(AtomicBool::new(false)); let startup_complete_for_fut = Arc::clone(&startup_complete); @@ -207,7 +216,7 @@ impl AsyncManagedClient { outcome }; let client = fut.boxed().shared(); - if startup_snapshot.is_some() { + if cached_tool_info_snapshot.is_some() { let startup_task = client.clone(); tokio::spawn(async move { let _ = startup_task.await; @@ -216,7 +225,8 @@ impl AsyncManagedClient { Self { client, - startup_snapshot, + cached_tool_info_snapshot, + cached_server_info, startup_complete, tool_plugin_provenance, cancel_token, @@ -238,9 +248,9 @@ impl AsyncManagedClient { } } - fn startup_snapshot_while_initializing(&self) -> Option> { + fn cached_tool_info_snapshot_while_initializing(&self) -> Option> { if !self.startup_complete.load(Ordering::Acquire) { - return self.startup_snapshot.clone(); + return self.cached_tool_info_snapshot.clone(); } None } @@ -298,12 +308,13 @@ impl AsyncManagedClient { }; // Keep cache payloads raw; plugin provenance is resolved per-session at read time. - let tools = if let Some(startup_tools) = self.startup_snapshot_while_initializing() { + let tools = if let Some(startup_tools) = self.cached_tool_info_snapshot_while_initializing() + { Some(startup_tools) } else { match self.client().await { Ok(client) => Some(client.listed_tools()), - Err(_) => self.startup_snapshot.clone(), + Err(_) => self.cached_tool_info_snapshot.clone(), } }; tools.map(annotate_tools) @@ -470,26 +481,13 @@ async fn start_server_task( codex_apps_tools_cache_context, client_elicitation_capability, } = params; - let params = InitializeRequestParams { - meta: None, - capabilities: ClientCapabilities { - experimental: None, - extensions: None, - roots: None, - sampling: None, - elicitation: Some(client_elicitation_capability), - tasks: None, - }, - client_info: Implementation { - name: "codex-mcp-client".to_owned(), - version: env!("CARGO_PKG_VERSION").to_owned(), - title: Some("Codex".into()), - description: None, - icons: None, - website_url: None, - }, - protocol_version: ProtocolVersion::V_2025_06_18, - }; + let mut capabilities = ClientCapabilities::default(); + capabilities.elicitation = Some(client_elicitation_capability); + let params = InitializeRequestParams::new( + capabilities, + Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18); let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event); @@ -519,9 +517,11 @@ async fn start_server_task( fetch_start.elapsed(), &[], ); + let server_info = mcp_server_info_from_implementation(initialize_result.server_info); write_cached_codex_apps_tools_if_needed( &server_name, codex_apps_tools_cache_context.as_ref(), + &server_info, &tools, ); if server_name == CODEX_APPS_MCP_SERVER_NAME { @@ -535,6 +535,7 @@ async fn start_server_task( let managed = ManagedClient { client: Arc::clone(&client), + server_info, tools, tool_timeout: Some(tool_timeout), tool_filter, @@ -546,6 +547,22 @@ async fn start_server_task( Ok(managed) } +fn mcp_server_info_from_implementation(server_info: Implementation) -> McpServerInfo { + McpServerInfo { + name: server_info.name, + title: server_info.title, + version: server_info.version, + description: server_info.description, + icons: server_info.icons.map(|icons| { + icons + .into_iter() + .filter_map(|icon| serde_json::to_value(icon).ok()) + .collect() + }), + website_url: server_info.website_url, + } +} + struct StartServerTaskParams { startup_timeout: Option, // TODO: cancel_token should handle this. tool_timeout: Duration, @@ -647,32 +664,27 @@ mod tests { use rmcp::model::Meta; fn tool_with_connector_meta() -> RmcpTool { - RmcpTool { - name: "capture_file_upload".to_string().into(), - title: None, - description: Some("test tool".to_string().into()), - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: Some(Meta( - serde_json::json!({ - "connector_id": "connector_gmail", - "connector_name": "Gmail", - "connector_display_name": "Gmail", - "connector_description": "Mail connector", - "connectorDescription": "Mail connector", - "connectorFutureField": "future connector metadata", - "CONNECTOR_UPPERCASE": "uppercase connector metadata", - "openai/fileParams": ["file"], - "custom": "kept" - }) - .as_object() - .expect("object") - .clone(), - )), - } + RmcpTool::new( + "capture_file_upload", + "test tool", + Arc::new(JsonObject::default()), + ) + .with_meta(Meta( + serde_json::json!({ + "connector_id": "connector_gmail", + "connector_name": "Gmail", + "connector_display_name": "Gmail", + "connector_description": "Mail connector", + "connectorDescription": "Mail connector", + "connectorFutureField": "future connector metadata", + "CONNECTOR_UPPERCASE": "uppercase connector metadata", + "openai/fileParams": ["file"], + "custom": "kept" + }) + .as_object() + .expect("object") + .clone(), + )) } #[test] diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 20a0769d83..3dbf483c2d 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -861,7 +861,7 @@ fn project_config_for_lookup_key( .iter() .filter(|(key, _)| normalize_project_lookup_key((*key).clone()) == lookup_key) .collect(); - normalized_matches.sort_by(|(left, _), (right, _)| left.cmp(right)); + normalized_matches.sort_by_key(|(key, _)| *key); normalized_matches .first() .map(|(_, project_config)| (**project_config).clone()) diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index e8972ce5ad..3c1723e51b 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -1006,7 +1006,7 @@ fn project_trust_for_lookup_key( .iter() .filter(|(key, _)| normalize_project_trust_lookup_key((*key).clone()) == lookup_key) .collect(); - normalized_matches.sort_by(|(left, _), (right, _)| left.cmp(right)); + normalized_matches.sort_by_key(|(key, _)| *key); normalized_matches .first() .map(|(key, trust_level)| ((**key).clone(), **trust_level)) diff --git a/codex-rs/config/src/mcp_edit.rs b/codex-rs/config/src/mcp_edit.rs index 7e062e2998..9f007de113 100644 --- a/codex-rs/config/src/mcp_edit.rs +++ b/codex-rs/config/src/mcp_edit.rs @@ -230,7 +230,7 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { let mut tools = TomlTable::new(); tools.set_implicit(false); let mut tool_entries: Vec<_> = config.tools.iter().collect(); - tool_entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + tool_entries.sort_by_key(|(name, _)| *name); for (name, tool_config) in tool_entries { let mut tool_entry = TomlTable::new(); tool_entry.set_implicit(false); @@ -280,7 +280,7 @@ where I: IntoIterator, { let mut entries: Vec<_> = pairs.into_iter().collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + entries.sort_by_key(|(key, _)| *key); let mut table = TomlTable::new(); table.set_implicit(false); for (key, value_str) in entries { diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index df0de3b1cd..e0d2c70671 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -99,7 +99,7 @@ pub fn canonicalize(value: &Value) -> Value { Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()), Value::Object(map) => { let mut entries: Vec<_> = map.iter().collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + entries.sort_by_key(|(key, _)| *key); let mut sorted = Map::with_capacity(map.len()); for (key, child) in entries { sorted.insert(key.clone(), canonicalize(child)); diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 5b1e7d4cc8..e063cac0e8 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -115,6 +115,8 @@ pub struct TuiGlobalKeymap { #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] pub struct TuiChatKeymap { + /// Interrupt the active turn. + pub interrupt_turn: Option, /// Decrease the active reasoning effort. pub decrease_reasoning_effort: Option, /// Increase the active reasoning effort. @@ -223,6 +225,8 @@ pub struct TuiVimNormalKeymap { pub delete_char: Option, /// Delete from cursor to end of line (`D`). pub delete_to_line_end: Option, + /// Change from cursor to end of line and enter insert mode (`C`). + pub change_to_line_end: Option, /// Yank the entire line (`Y`). pub yank_line: Option, /// Paste after cursor (`p`). @@ -231,6 +235,8 @@ pub struct TuiVimNormalKeymap { pub start_delete_operator: Option, /// Begin yank operator; next key selects motion (`y`). pub start_yank_operator: Option, + /// Begin change operator; next keys select a text object. + pub start_change_operator: Option, /// Cancel a pending operator and return to normal mode. pub cancel_operator: Option, } @@ -266,10 +272,39 @@ pub struct TuiVimOperatorKeymap { pub motion_line_start: Option, /// Motion: to end of line (`$`). pub motion_line_end: Option, + /// Select an inner text object after an operator. + pub select_inner_text_object: Option, + /// Select an around text object after an operator. + pub select_around_text_object: Option, /// Cancel the pending operator and return to normal mode. pub cancel: Option, } +/// Vim text-object keybindings for modal editing inside text areas. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiVimTextObjectKeymap { + /// Text object: word. + pub word: Option, + /// Text object: whitespace-delimited WORD. + pub big_word: Option, + /// Text object: parentheses. + pub parentheses: Option, + /// Text object: brackets. + pub brackets: Option, + /// Text object: braces. + pub braces: Option, + /// Text object: double quotes. + pub double_quote: Option, + /// Text object: single quotes. + pub single_quote: Option, + /// Text object: backticks. + pub backtick: Option, + /// Cancel the pending text-object command. + pub cancel: Option, +} + /// Pager context keybindings for transcript and static overlays. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[serde(deny_unknown_fields)] @@ -374,6 +409,8 @@ pub struct TuiKeymap { #[serde(default)] pub vim_operator: TuiVimOperatorKeymap, #[serde(default)] + pub vim_text_object: TuiVimTextObjectKeymap, + #[serde(default)] pub pager: TuiPagerKeymap, #[serde(default)] pub list: TuiListKeymap, @@ -560,6 +597,20 @@ mod tests { ); } + #[test] + fn misspelled_vim_text_object_action_is_rejected() { + let toml_input = r#" + [vim_text_object] + double_quotes = "shift-quote" + "#; + let err = toml::from_str::(toml_input) + .expect_err("expected unknown vim text object action"); + assert!( + err.to_string().contains("double_quotes"), + "expected error to mention misspelled field, got: {err}" + ); + } + #[test] fn removed_backtrack_actions_are_rejected() { for (context, action) in [ diff --git a/codex-rs/core-plugins/src/marketplace_upgrade.rs b/codex-rs/core-plugins/src/marketplace_upgrade.rs index 9dda8a9a3e..010694c8b3 100644 --- a/codex-rs/core-plugins/src/marketplace_upgrade.rs +++ b/codex-rs/core-plugins/src/marketplace_upgrade.rs @@ -6,6 +6,7 @@ use self::activation::installed_marketplace_metadata_matches; use self::activation::write_installed_marketplace_metadata; use self::git::clone_git_source; use self::git::git_remote_revision; +use crate::marketplace::find_marketplace_manifest_path; use crate::marketplace::validate_marketplace_root; use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigLayerStack; @@ -176,9 +177,7 @@ fn upgrade_configured_git_marketplace( MARKETPLACE_UPGRADE_GIT_TIMEOUT, )?; let destination = install_root.join(&marketplace.name); - if destination - .join(".agents/plugins/marketplace.json") - .is_file() + if find_marketplace_manifest_path(&destination).is_some() && marketplace.last_revision.as_deref() == Some(remote_revision.as_str()) && installed_marketplace_metadata_matches(&destination, marketplace, &remote_revision) { diff --git a/codex-rs/core-skills/src/injection.rs b/codex-rs/core-skills/src/injection.rs index df62f42e85..45979fa5c4 100644 --- a/codex-rs/core-skills/src/injection.rs +++ b/codex-rs/core-skills/src/injection.rs @@ -132,7 +132,7 @@ pub fn collect_explicit_skill_mentions( let mut blocked_plain_names: HashSet = HashSet::new(); for input in inputs { - if let UserInput::Skill { name, path } = input { + if let UserInput::Skill { name, path, .. } = input { blocked_plain_names.insert(name.clone()); let Ok(path) = AbsolutePathBuf::relative_to_current_dir(path) else { continue; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6af30b4071..1dd6bda8a4 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -473,6 +473,9 @@ "image_generation": { "type": "boolean" }, + "imagegenext": { + "type": "boolean" + }, "in_app_browser": { "type": "boolean" }, @@ -2766,7 +2769,8 @@ "chat": { "decrease_reasoning_effort": null, "edit_queued_message": null, - "increase_reasoning_effort": null + "increase_reasoning_effort": null, + "interrupt_turn": null }, "composer": { "history_search_next": null, @@ -2834,6 +2838,7 @@ "append_after_cursor": null, "append_line_end": null, "cancel_operator": null, + "change_to_line_end": null, "delete_char": null, "delete_to_line_end": null, "enter_insert": null, @@ -2850,6 +2855,7 @@ "open_line_above": null, "open_line_below": null, "paste_after": null, + "start_change_operator": null, "start_delete_operator": null, "start_yank_operator": null, "yank_line": null @@ -2866,7 +2872,20 @@ "motion_word_backward": null, "motion_word_end": null, "motion_word_forward": null, + "select_around_text_object": null, + "select_inner_text_object": null, "yank_line": null + }, + "vim_text_object": { + "backtick": null, + "big_word": null, + "braces": null, + "brackets": null, + "cancel": null, + "double_quote": null, + "parentheses": null, + "single_quote": null, + "word": null } }, "description": "Keybinding overrides for the TUI.\n\nThis supports rebinding selected actions globally and by context. Context bindings take precedence over `global` bindings." @@ -3079,6 +3098,14 @@ } ], "description": "Increase the active reasoning effort." + }, + "interrupt_turn": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Interrupt the active turn." } }, "type": "object" @@ -3390,7 +3417,8 @@ "default": { "decrease_reasoning_effort": null, "edit_queued_message": null, - "increase_reasoning_effort": null + "increase_reasoning_effort": null, + "interrupt_turn": null } }, "composer": { @@ -3500,6 +3528,7 @@ "append_after_cursor": null, "append_line_end": null, "cancel_operator": null, + "change_to_line_end": null, "delete_char": null, "delete_to_line_end": null, "enter_insert": null, @@ -3516,6 +3545,7 @@ "open_line_above": null, "open_line_below": null, "paste_after": null, + "start_change_operator": null, "start_delete_operator": null, "start_yank_operator": null, "yank_line": null @@ -3539,8 +3569,28 @@ "motion_word_backward": null, "motion_word_end": null, "motion_word_forward": null, + "select_around_text_object": null, + "select_inner_text_object": null, "yank_line": null } + }, + "vim_text_object": { + "allOf": [ + { + "$ref": "#/definitions/TuiVimTextObjectKeymap" + } + ], + "default": { + "backtick": null, + "big_word": null, + "braces": null, + "brackets": null, + "cancel": null, + "double_quote": null, + "parentheses": null, + "single_quote": null, + "word": null + } } }, "type": "object" @@ -3765,6 +3815,14 @@ ], "description": "Cancel a pending operator and return to normal mode." }, + "change_to_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Change from cursor to end of line and enter insert mode (`C`)." + }, "delete_char": { "allOf": [ { @@ -3893,6 +3951,14 @@ ], "description": "Paste after cursor (`p`)." }, + "start_change_operator": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Begin change operator; next keys select a text object." + }, "start_delete_operator": { "allOf": [ { @@ -4012,6 +4078,22 @@ ], "description": "Motion: to start of next word (`w`)." }, + "select_around_text_object": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Select an around text object after an operator." + }, + "select_inner_text_object": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Select an inner text object after an operator." + }, "yank_line": { "allOf": [ { @@ -4023,6 +4105,85 @@ }, "type": "object" }, + "TuiVimTextObjectKeymap": { + "additionalProperties": false, + "description": "Vim text-object keybindings for modal editing inside text areas.", + "properties": { + "backtick": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: backticks." + }, + "big_word": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: whitespace-delimited WORD." + }, + "braces": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: braces." + }, + "brackets": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: brackets." + }, + "cancel": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Cancel the pending text-object command." + }, + "double_quote": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: double quotes." + }, + "parentheses": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: parentheses." + }, + "single_quote": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: single quotes." + }, + "word": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: word." + } + }, + "type": "object" + }, "UriBasedFileOpener": { "oneOf": [ { @@ -4406,6 +4567,9 @@ "image_generation": { "type": "boolean" }, + "imagegenext": { + "type": "boolean" + }, "in_app_browser": { "type": "boolean" }, diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 4706a455ff..7e87bf73d6 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -317,6 +317,7 @@ impl AgentControl { .services .analytics_events_client, client_metadata, + new_thread.thread.codex.session.session_id(), new_thread.thread_id, /*parent_thread_id*/ None, thread_config, @@ -703,22 +704,6 @@ impl AgentControl { result } - /// Append a prebuilt message to an existing agent thread outside the normal user-input path. - #[cfg(test)] - pub(crate) async fn append_message( - &self, - agent_id: ThreadId, - message: ResponseItem, - ) -> CodexResult { - let state = self.upgrade()?; - self.handle_thread_request_result( - agent_id, - &state, - state.append_message(agent_id, message).await, - ) - .await - } - pub(crate) async fn send_inter_agent_communication( &self, agent_id: ThreadId, @@ -787,15 +772,45 @@ impl AgentControl { /// agent and any live descendants reached from the in-memory tree. pub(crate) async fn close_agent(&self, agent_id: ThreadId) -> CodexResult { let state = self.upgrade()?; - if let Ok(thread) = state.get_thread(agent_id).await - && let Some(state_db_ctx) = thread.state_db() - && let Err(err) = state_db_ctx - .set_thread_spawn_edge_status(agent_id, DirectionalThreadSpawnEdgeStatus::Closed) - .await - { - warn!("failed to persist thread-spawn edge status for {agent_id}: {err}"); + let known_agent = self.state.agent_metadata_for_thread(agent_id).is_some(); + match state.get_thread(agent_id).await { + Ok(thread) => { + if let Some(state_db_ctx) = thread.state_db() + && let Err(err) = state_db_ctx + .set_thread_spawn_edge_status( + agent_id, + DirectionalThreadSpawnEdgeStatus::Closed, + ) + .await + { + warn!("failed to persist thread-spawn edge status for {agent_id}: {err}"); + } + } + Err(CodexErr::ThreadNotFound(_)) if known_agent => { + if let Some(state_db_ctx) = state.state_db() + && let Err(err) = state_db_ctx + .set_thread_spawn_edge_status( + agent_id, + DirectionalThreadSpawnEdgeStatus::Closed, + ) + .await + { + return Err(CodexErr::Fatal(format!( + "failed to persist stale thread-spawn edge status for {agent_id}: {err}" + ))); + } + } + Err(CodexErr::ThreadNotFound(_)) => {} + Err(err) => { + warn!("failed to inspect agent before close {agent_id}: {err}"); + } + } + match Box::pin(self.shutdown_agent_tree(agent_id)).await { + Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) if known_agent => { + Ok(String::new()) + } + result => result, } - Box::pin(self.shutdown_agent_tree(agent_id)).await } /// Shut down `agent_id` and any live descendants reachable from the in-memory spawn tree. @@ -1281,8 +1296,10 @@ pub(crate) fn render_input_preview(initial_operation: &Op) -> String { UserInput::LocalImage { path, .. } => { format!("[local_image:{}]", path.display()) } - UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()), - UserInput::Mention { name, path } => format!("[mention:${name}]({path})"), + UserInput::Skill { name, path, .. } => { + format!("[skill:${name}]({})", path.display()) + } + UserInput::Mention { name, path, .. } => format!("[mention:${name}]({path})"), _ => "[input]".to_string(), }) .collect::>() diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 8209272f10..43a6e47bfd 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -518,60 +518,6 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig )); } -#[tokio::test] -async fn append_message_records_assistant_message() { - let harness = AgentControlHarness::new().await; - let (thread_id, thread) = harness.start_thread().await; - let message = - "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: hello from tests"; - - let submission_id = harness - .control - .append_message( - thread_id, - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::InputText { - text: message.to_string(), - }], - phase: None, - }, - ) - .await - .expect("append_message should succeed"); - assert!(!submission_id.is_empty()); - - timeout(Duration::from_secs(5), async { - loop { - let history_items = thread - .codex - .session - .clone_history() - .await - .raw_items() - .to_vec(); - let recorded = history_items.iter().any(|item| { - matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "assistant" - && content.iter().any(|content_item| matches!( - content_item, - ContentItem::InputText { text } if text == message - )) - ) - }); - if recorded { - break; - } - sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("assistant message should be recorded"); -} - #[tokio::test] async fn spawn_agent_creates_thread_and_sends_prompt() { let harness = AgentControlHarness::new().await; @@ -1113,6 +1059,108 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { .expect("parent shutdown should submit"); } +#[tokio::test] +async fn spawn_agent_fork_last_n_turns_drops_parent_startup_prefix_when_under_limit() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + let startup_turn_context = parent_thread.codex.session.new_default_turn().await; + parent_thread + .codex + .session + .record_conversation_items( + startup_turn_context.as_ref(), + &[ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "parent startup developer context".to_string(), + }], + phase: None, + }], + ) + .await; + parent_thread + .inject_user_message_without_turn("current parent task".to_string()) + .await; + let spawn_turn_context = parent_thread.codex.session.new_default_turn().await; + let parent_spawn_call_id = "spawn-call-last-n-under-limit".to_string(); + parent_thread + .codex + .session + .record_conversation_items( + spawn_turn_context.as_ref(), + &[spawn_agent_call(&parent_spawn_call_id)], + ) + .await; + parent_thread + .codex + .session + .ensure_rollout_materialized() + .await; + parent_thread + .codex + .session + .flush_rollout() + .await + .expect("parent rollout should flush"); + + let child_thread_id = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id), + fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)), + ..Default::default() + }, + ) + .await + .expect("bounded forked spawn should drop startup prefix") + .thread_id; + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let history = child_thread.codex.session.clone_history().await; + assert!( + history_contains_text(history.raw_items(), "current parent task"), + "bounded fork should retain the requested recent parent turn" + ); + assert!( + !history_contains_text(history.raw_items(), "parent startup developer context"), + "bounded fork should drop parent startup context even when fewer turns exist than requested" + ); + assert!( + child_thread + .codex + .session + .reference_context_item() + .await + .is_none(), + "bounded forked child should still rebuild context after truncating the cached prefix" + ); + + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let _ = parent_thread + .submit(Op::Shutdown {}) + .await + .expect("parent shutdown should submit"); +} + #[tokio::test] async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index d84b32a8b4..f47be864fb 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -218,6 +218,7 @@ impl RequestRouteTelemetry { #[derive(Debug, Clone)] pub struct ModelClient { state: Arc, + prompt_cache_key_override: Option, } /// A turn-scoped streaming session created from a [`ModelClient`]. @@ -352,9 +353,24 @@ impl ModelClient { disable_websockets: AtomicBool::new(false), cached_websocket_session: StdMutex::new(WebsocketSession::default()), }), + prompt_cache_key_override: None, } } + pub(crate) fn with_prompt_cache_key_override( + mut self, + prompt_cache_key_override: Option, + ) -> Self { + self.prompt_cache_key_override = prompt_cache_key_override; + self + } + + fn prompt_cache_key(&self) -> String { + self.prompt_cache_key_override + .clone() + .unwrap_or_else(|| self.state.thread_id.to_string()) + } + /// Creates a fresh turn-scoped streaming session. /// /// This constructor does not perform network I/O itself; the session opens a websocket lazily @@ -383,7 +399,7 @@ impl ModelClient { self.store_cached_websocket_session(WebsocketSession::default()); } - fn current_window_id(&self) -> String { + pub(crate) fn current_window_id(&self) -> String { let thread_id = self.state.thread_id; let window_generation = self.state.window_generation.load(Ordering::Relaxed); format!("{thread_id}:{window_generation}") @@ -441,6 +457,7 @@ impl ModelClient { settings: CompactConversationRequestSettings, session_telemetry: &SessionTelemetry, compaction_trace: &CompactionTraceContext, + turn_metadata_header: Option<&str>, ) -> Result> { if prompt.input.is_empty() { return Ok(Vec::new()); @@ -496,7 +513,7 @@ impl ModelClient { extra_headers.extend(build_responses_headers( self.state.beta_features_header.as_deref(), /*turn_state*/ None, - /*turn_metadata_header*/ None, + parse_turn_metadata_header(turn_metadata_header).as_ref(), )); extra_headers.extend(self.build_responses_identity_headers()); extra_headers.extend(build_session_headers( @@ -748,7 +765,7 @@ impl ModelClient { &prompt.output_schema, prompt.output_schema_strict, ); - let prompt_cache_key = Some(self.state.thread_id.to_string()); + let prompt_cache_key = Some(self.prompt_cache_key()); let service_tier = model_info.service_tier_for_request(service_tier); let request = ResponsesApiRequest { model: model_info.slug.clone(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index dd88e9238f..3c55f63761 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -109,6 +109,7 @@ pub(crate) async fn run_codex_thread_interactive( emit_subagent_session_started( &parent_session.services.analytics_events_client, client_metadata, + codex.session.session_id(), codex.session.conversation_id, Some(parent_session.conversation_id), thread_config, @@ -217,6 +218,7 @@ pub(crate) async fn run_codex_thread_one_shot( .send(Submission { id: "shutdown".to_string(), op: Op::Shutdown {}, + client_user_message_id: None, trace: None, }) .await; diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 66cde8d1ea..04f6ae3965 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -129,6 +129,7 @@ async fn forward_ops_preserves_submission_trace_context() { let submission = Submission { id: "sub-1".to_string(), op: Op::Interrupt, + client_user_message_id: None, trace: Some(codex_protocol::protocol::W3cTraceContext { traceparent: Some( "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01".to_string(), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 1113a12c8a..21f9ec4549 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -230,6 +230,17 @@ impl CodexThread { self.codex.submit_with_trace(op, trace).await } + pub async fn submit_user_input_with_client_user_message_id( + &self, + op: Op, + trace: Option, + client_user_message_id: Option, + ) -> CodexResult { + self.codex + .submit_user_input_with_client_user_message_id(op, trace, client_user_message_id) + .await + } + /// Persist whether this thread is eligible for future memory generation. pub async fn set_thread_memory_mode(&self, mode: ThreadMemoryMode) -> anyhow::Result<()> { self.codex.set_thread_memory_mode(mode).await @@ -240,6 +251,7 @@ impl CodexThread { input: Vec, additional_context: BTreeMap, expected_turn_id: Option<&str>, + client_user_message_id: Option, responsesapi_client_metadata: Option>, ) -> Result { self.codex @@ -247,6 +259,7 @@ impl CodexThread { input, additional_context, expected_turn_id, + client_user_message_id, responsesapi_client_metadata, ) .await @@ -260,7 +273,12 @@ impl CodexThread { &self, items: Vec, ) -> Result<(), Vec> { - self.codex.session.inject_response_items(items).await + let response_items = items.iter().cloned().map(ResponseItem::from).collect(); + self.codex + .session + .inject_if_running(response_items) + .await + .map_err(|_| items) } pub async fn set_app_server_client_info( @@ -366,58 +384,16 @@ impl CodexThread { /// Records a user-role session-prefix message without creating a new user turn boundary. pub(crate) async fn inject_user_message_without_turn(&self, message: String) { - let message = ResponseItem::Message { + let item = ResponseItem::Message { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { text: message }], phase: None, }; - let pending_item = match pending_message_input_item(&message) { - Ok(pending_item) => pending_item, - Err(err) => { - debug_assert!(false, "session-prefix message append should succeed: {err}"); - return; - } - }; - if self - .codex + self.codex .session - .inject_response_items(vec![pending_item]) - .await - .is_err() - { - let turn_context = self.codex.session.new_default_turn().await; - self.codex - .session - .record_conversation_items(turn_context.as_ref(), &[message]) - .await; - } - } - - /// Append a prebuilt message to the thread history without treating it as a user turn. - /// - /// If the thread already has an active turn, the message is queued as pending input for that - /// turn. Otherwise it is queued at session scope and a regular turn is started so the agent - /// can consume that pending input through the normal turn pipeline. - #[cfg(test)] - pub(crate) async fn append_message(&self, message: ResponseItem) -> CodexResult { - let submission_id = uuid::Uuid::new_v4().to_string(); - let pending_item = pending_message_input_item(&message)?; - if let Err(items) = self - .codex - .session - .inject_response_items(vec![pending_item]) - .await - { - self.codex - .session - .input_queue - .queue_response_items_for_next_turn(items) - .await; - self.codex.session.maybe_start_turn_for_pending_work().await; - } - - Ok(submission_id) + .inject_no_new_turn(vec![item], /*current_turn_context*/ None) + .await; } /// Append raw Responses API items to the thread's model-visible history. @@ -437,7 +413,7 @@ impl CodexThread { } self.codex .session - .record_conversation_items(turn_context.as_ref(), &items) + .inject_no_new_turn(items, Some(turn_context.as_ref())) .await; self.codex.session.flush_rollout().await?; Ok(()) @@ -540,13 +516,7 @@ impl CodexThread { let result = self .codex .session - .read_resource( - server, - ReadResourceRequestParams { - meta: None, - uri: uri.to_string(), - }, - ) + .read_resource(server, ReadResourceRequestParams::new(uri)) .await?; Ok(serde_json::to_value(result)?) @@ -604,21 +574,3 @@ impl CodexThread { Ok(*guard) } } - -fn pending_message_input_item(message: &ResponseItem) -> CodexResult { - match message { - ResponseItem::Message { - role, - content, - phase, - .. - } => Ok(ResponseInputItem::Message { - role: role.clone(), - content: content.clone(), - phase: phase.clone(), - }), - _ => Err(CodexErr::InvalidRequest( - "append_message only supports ResponseItem::Message".to_string(), - )), - } -} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index f2bded8d06..fd388b273c 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -13,6 +13,7 @@ use crate::session::PreviousTurnSettings; use crate::session::session::Session; use crate::session::turn::get_last_assistant_message_from_turn; use crate::session::turn_context::TurnContext; +use crate::turn_metadata::CompactionTurnMetadata; use crate::util::backoff; use codex_analytics::CodexCompactionEvent; use codex_analytics::CompactionImplementation; @@ -128,6 +129,8 @@ async fn run_compact_task_inner( reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { + let compaction_metadata = + CompactionTurnMetadata::new(trigger, reason, CompactionImplementation::Responses, phase); let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), @@ -153,6 +156,7 @@ async fn run_compact_task_inner( Arc::clone(&turn_context), input, initial_context_injection, + compaction_metadata, ) .await; let status = compaction_status_from_result(&result); @@ -173,6 +177,7 @@ async fn run_compact_task_inner_impl( turn_context: Arc, input: Vec, initial_context_injection: InitialContextInjection, + compaction_metadata: CompactionTurnMetadata, ) -> CodexResult { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(&turn_context, &compaction_item) @@ -204,7 +209,10 @@ async fn run_compact_task_inner_impl( personality: turn_context.personality, ..Default::default() }; - let turn_metadata_header = turn_context.turn_metadata_state.current_header_value(); + let window_id = sess.services.model_client.current_window_id(); + let turn_metadata_header = turn_context + .turn_metadata_state + .current_header_value_for_compaction(&window_id, compaction_metadata); let attempt_result = drain_to_completed( &sess, turn_context.as_ref(), @@ -560,7 +568,7 @@ async fn drain_to_completed( }; match event { Ok(ResponseEvent::OutputItemDone(item)) => { - sess.record_into_history(std::slice::from_ref(&item), turn_context) + sess.record_conversation_items(turn_context, std::slice::from_ref(&item)) .await; } Ok(ResponseEvent::ServerReasoningIncluded(included)) => { diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 969da47544..b4456dc717 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -17,6 +17,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::turn_metadata::CompactionTurnMetadata; use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; @@ -89,6 +90,12 @@ async fn run_remote_compact_task_inner( reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { + let compaction_metadata = CompactionTurnMetadata::new( + trigger, + reason, + CompactionImplementation::ResponsesCompact, + phase, + ); let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), @@ -113,8 +120,13 @@ async fn run_remote_compact_task_inner( return Err(CodexErr::TurnAborted); } } - let result = - run_remote_compact_task_inner_impl(sess, turn_context, initial_context_injection).await; + let result = run_remote_compact_task_inner_impl( + sess, + turn_context, + initial_context_injection, + compaction_metadata, + ) + .await; let status = compaction_status_from_result(&result); let error = result.as_ref().err().map(ToString::to_string); if result.is_ok() { @@ -139,6 +151,7 @@ async fn run_remote_compact_task_inner_impl( sess: &Arc, turn_context: &Arc, initial_context_injection: InitialContextInjection, + compaction_metadata: CompactionTurnMetadata, ) -> CodexResult<()> { let context_compaction_item = ContextCompactionItem::new(); // Use the UI compaction item ID as the trace compaction ID so protocol lifecycle events, @@ -186,6 +199,10 @@ async fn run_remote_compact_task_inner_impl( output_schema: None, output_schema_strict: true, }; + let window_id = sess.services.model_client.current_window_id(); + let turn_metadata_header = turn_context + .turn_metadata_state + .current_header_value_for_compaction(&window_id, compaction_metadata); let mut new_history = sess .services .model_client @@ -203,6 +220,7 @@ async fn run_remote_compact_task_inner_impl( }, &turn_context.session_telemetry, &compaction_trace, + turn_metadata_header.as_deref(), ) .or_else(|err| async { let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index dcf241f990..0da3017f7d 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -21,6 +21,7 @@ use crate::responses_retry::handle_retryable_response_stream_error; use crate::session::session::Session; use crate::session::turn::built_tools; use crate::session::turn_context::TurnContext; +use crate::turn_metadata::CompactionTurnMetadata; use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; @@ -105,6 +106,12 @@ async fn run_remote_compact_task_inner( reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { + let compaction_metadata = CompactionTurnMetadata::new( + trigger, + reason, + CompactionImplementation::ResponsesCompactionV2, + phase, + ); let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), @@ -134,6 +141,7 @@ async fn run_remote_compact_task_inner( turn_context, client_session, initial_context_injection, + compaction_metadata, ) .await; let status = compaction_status_from_result(&result); @@ -161,6 +169,7 @@ async fn run_remote_compact_task_inner_impl( turn_context: &Arc, client_session: Option<&mut ModelClientSession>, initial_context_injection: InitialContextInjection, + compaction_metadata: CompactionTurnMetadata, ) -> CodexResult<()> { let context_compaction_item = ContextCompactionItem::new(); let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( @@ -208,7 +217,10 @@ async fn run_remote_compact_task_inner_impl( output_schema_strict: true, }; - let turn_metadata_header = turn_context.turn_metadata_state.current_header_value(); + let window_id = sess.services.model_client.current_window_id(); + let turn_metadata_header = turn_context + .turn_metadata_state + .current_header_value_for_compaction(&window_id, compaction_metadata); let trace_attempt = compaction_trace.start_attempt(&serde_json::json!({ "model": turn_context.model_info.slug.as_str(), "instructions": prompt.base_instructions.text.as_str(), diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index e8866aab3a..9501035625 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1554,7 +1554,6 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: .await?; let cwd_root = cwd.path().abs(); - let memories_root = codex_home.path().join("memories").abs(); assert_eq!( config.permissions.file_system_sandbox_policy(), FileSystemSandboxPolicy::restricted(vec![ @@ -1576,18 +1575,12 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: }, access: FileSystemAccessMode::Read, }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: memories_root.clone(), - }, - access: FileSystemAccessMode::Write, - }, ]), ); assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![memories_root], + writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1816,7 +1809,7 @@ async fn managed_unrestricted_permission_profile_still_enables_network_requireme } #[tokio::test] -async fn permission_profile_override_applies_runtime_roots_to_legacy_projection() +async fn permission_profile_override_keeps_memories_root_out_of_legacy_projection() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; @@ -1851,7 +1844,7 @@ async fn permission_profile_override_applies_runtime_roots_to_legacy_projection( let memories_root = codex_home.path().join("memories").abs(); assert!( - config + !config .permissions .file_system_sandbox_policy() .can_write_path_with_cwd(memories_root.as_path(), cwd.path()) @@ -1859,7 +1852,7 @@ async fn permission_profile_override_applies_runtime_roots_to_legacy_projection( assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![memories_root], + writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -2757,9 +2750,6 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() description: Some("Workspace access.".to_string()), }] ); - let memories_root = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( - codex_home.path().join("memories"), - )?)?; assert!( config .permissions @@ -2769,7 +2759,7 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![external_write_path, memories_root], + writable_roots: vec![external_write_path], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -4514,13 +4504,15 @@ async fn sqlite_home_defaults_to_codex_home_for_workspace_write() -> std::io::Re } #[tokio::test] -async fn workspace_write_always_includes_memories_root_once() -> std::io::Result<()> { +async fn workspace_write_includes_configured_writable_root_once_without_memories_root() +-> std::io::Result<()> { let codex_home = TempDir::new()?; let memories_root = codex_home.path().join("memories"); + let writable_root = codex_home.path().join("writable").abs(); let config = Config::load_from_base_config_with_overrides( ConfigToml { sandbox_workspace_write: Some(SandboxWorkspaceWrite { - writable_roots: vec![memories_root.abs()], + writable_roots: vec![writable_root.clone(), writable_root.clone()], ..Default::default() }), ..Default::default() @@ -4540,21 +4532,22 @@ async fn workspace_write_always_includes_memories_root_once() -> std::io::Result } } else { assert!( - memories_root.is_dir(), - "expected memories root directory to exist at {}", + !memories_root.exists(), + "expected config load not to create memories root at {}", memories_root.display() ); let expected_memories_root = memories_root.abs(); match &config.legacy_sandbox_policy() { SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + assert!(!writable_roots.contains(&expected_memories_root)); assert_eq!( writable_roots .iter() - .filter(|root| **root == expected_memories_root) + .filter(|root| **root == writable_root) .count(), 1, "expected single writable root entry for {}", - expected_memories_root.display() + writable_root.display() ); } other => panic!("expected workspace-write policy, got {other:?}"), @@ -4564,6 +4557,62 @@ async fn workspace_write_always_includes_memories_root_once() -> std::io::Result Ok(()) } +#[tokio::test] +async fn memory_tool_makes_memories_root_readable_without_creating_or_widening_writes() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let memories_root = codex_home.path().join("memories"); + let memories_root_abs = memories_root.abs(); + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + features: Some(FeaturesToml::from(BTreeMap::from([( + "memories".to_string(), + true, + )]))), + sandbox_workspace_write: Some(SandboxWorkspaceWrite { + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + ..Default::default() + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + sandbox_mode: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert!( + !memories_root.exists(), + "expected config load not to create memories root at {}", + memories_root.display() + ); + let file_system_policy = config.permissions.file_system_sandbox_policy(); + assert!(file_system_policy.can_read_path_with_cwd(memories_root_abs.as_path(), cwd.path())); + assert!(!file_system_policy.can_write_path_with_cwd(memories_root_abs.as_path(), cwd.path())); + + if cfg!(target_os = "windows") { + match &config.legacy_sandbox_policy() { + SandboxPolicy::ReadOnly { .. } => {} + other => panic!("expected read-only policy on Windows, got {other:?}"), + } + } else { + match &config.legacy_sandbox_policy() { + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + assert!(!writable_roots.contains(&memories_root_abs)); + } + other => panic!("expected workspace-write policy, got {other:?}"), + } + } + + Ok(()) +} + #[tokio::test] async fn config_defaults_to_file_cli_auth_store_mode() -> 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 ca54a7c3f5..290edf6fb8 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -179,7 +179,7 @@ pub fn keymap_binding_clear_edit(context: &str, action: &str) -> ConfigEdit { pub fn model_availability_nux_count_edits(shown_count: &HashMap) -> Vec { let mut shown_count_entries: Vec<_> = shown_count.iter().collect(); - shown_count_entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right)); + shown_count_entries.sort_unstable_by_key(|(left, _)| *left); let mut edits = vec![ConfigEdit::ClearPath { segments: vec!["tui".to_string(), "model_availability_nux".to_string()], @@ -354,7 +354,7 @@ mod document_helpers { if !config.tools.is_empty() { let mut tools = new_implicit_table(); let mut tool_entries: Vec<_> = config.tools.iter().collect(); - tool_entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + tool_entries.sort_by_key(|(name, _)| *name); for (name, tool_config) in tool_entries { tools.insert(name, serialize_mcp_server_tool(tool_config)); } @@ -501,7 +501,7 @@ mod document_helpers { I: IntoIterator, { let mut entries: Vec<_> = pairs.into_iter().collect(); - entries.sort_by(|(a, _), (b, _)| a.cmp(b)); + entries.sort_by_key(|(key, _)| *key); let mut table = TomlTable::new(); table.set_implicit(false); for (key, val) in entries { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3115921c2d..618520b7bc 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2631,9 +2631,8 @@ impl Config { Some(WindowsSandboxModeToml::Unelevated) => WindowsSandboxLevel::RestrictedToken, None => WindowsSandboxLevel::from_features(&features), }; + let memories_config: MemoriesConfig = cfg.memories.clone().unwrap_or_default().into(); let memories_root = memory_root(&codex_home); - std::fs::create_dir_all(&memories_root)?; - let internal_writable_roots = vec![memories_root]; let profiles_are_active = effective_permission_selection.profiles_are_active( default_permissions_override.as_deref(), @@ -2701,8 +2700,8 @@ impl Config { file_system_sandbox_policy, mut active_permission_profile, mut profile_workspace_roots, - ) = if let Some(mut permission_profile) = permission_profile { - let (mut file_system_sandbox_policy, network_sandbox_policy) = + ) = if let Some(permission_profile) = permission_profile { + let (file_system_sandbox_policy, _network_sandbox_policy) = permission_profile.to_runtime_permissions(); let configured_network_proxy_config = if profile_allows_configured_network_proxy(&permission_profile) @@ -2726,30 +2725,6 @@ impl Config { } else { NetworkProxyConfig::default() }; - let materialized_file_system_sandbox_policy = file_system_sandbox_policy - .clone() - .materialize_project_roots_with_workspace_roots(&workspace_roots); - let materialized_permission_profile = - PermissionProfile::from_runtime_permissions_with_enforcement( - permission_profile.enforcement(), - &materialized_file_system_sandbox_policy, - network_sandbox_policy, - ); - let sandbox_policy = compatibility_sandbox_policy_for_permission_profile( - &materialized_permission_profile, - &materialized_file_system_sandbox_policy, - network_sandbox_policy, - resolved_cwd.as_path(), - ); - if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { - file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); - permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( - permission_profile.enforcement(), - &file_system_sandbox_policy, - network_sandbox_policy, - ); - } ( configured_network_proxy_config, permission_profile, @@ -2794,7 +2769,7 @@ impl Config { dedupe_absolute_paths(&mut configured_workspace_roots); file_system_sandbox_policy = file_system_sandbox_policy .with_materialized_project_roots_for_workspace_roots(&configured_workspace_roots); - let mut permission_profile = if let Some(permission_profile) = + let permission_profile = if let Some(permission_profile) = builtin_permission_profile(default_permissions, builtin_workspace_write_settings) { permission_profile @@ -2804,30 +2779,6 @@ impl Config { network_sandbox_policy, ) }; - let materialized_file_system_sandbox_policy = file_system_sandbox_policy - .clone() - .materialize_project_roots_with_workspace_roots(&workspace_roots); - let materialized_permission_profile = - PermissionProfile::from_runtime_permissions_with_enforcement( - permission_profile.enforcement(), - &materialized_file_system_sandbox_policy, - network_sandbox_policy, - ); - let sandbox_policy = compatibility_sandbox_policy_for_permission_profile( - &materialized_permission_profile, - &materialized_file_system_sandbox_policy, - network_sandbox_policy, - resolved_cwd.as_path(), - ); - if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { - file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); - permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( - permission_profile.enforcement(), - &file_system_sandbox_policy, - network_sandbox_policy, - ); - } let active_permission_profile = if using_implicit_builtin_profile && default_permissions == BUILT_IN_WORKSPACE_PROFILE && cfg.sandbox_workspace_write.is_some() @@ -2885,29 +2836,8 @@ impl Config { ); permission_profile = PermissionProfile::read_only(); } - let (mut file_system_sandbox_policy, network_sandbox_policy) = + let (file_system_sandbox_policy, _network_sandbox_policy) = permission_profile.to_runtime_permissions(); - let materialized_file_system_sandbox_policy = permission_profile - .clone() - .materialize_project_roots_with_workspace_roots(&workspace_roots) - .file_system_sandbox_policy(); - if matches!(permission_profile.enforcement(), SandboxEnforcement::Managed) - && materialized_file_system_sandbox_policy.can_write_path_with_cwd( - resolved_cwd.as_path(), - resolved_cwd.as_path(), - ) - && !materialized_file_system_sandbox_policy.has_full_disk_write_access() - { - // Keep Codex runtime write access while storing the runtime - // workspace roots separately on the thread. - file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); - permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( - permission_profile.enforcement(), - &file_system_sandbox_policy, - network_sandbox_policy, - ); - } ( configured_network_proxy_config, permission_profile, @@ -3324,11 +3254,14 @@ impl Config { network_requirements, &network_permission_profile, )?; - let helper_readable_roots = get_readable_roots_required_for_codex_runtime( + let mut helper_readable_roots = get_readable_roots_required_for_codex_runtime( &codex_home, zsh_path.as_ref(), main_execve_wrapper_exe.as_ref(), ); + if features.enabled(Feature::MemoryTool) && memories_config.use_memories { + helper_readable_roots.push(memories_root); + } let effective_permission_profile = constrained_permission_profile.value.get().clone(); let (mut effective_file_system_sandbox_policy, effective_network_sandbox_policy) = effective_permission_profile.to_runtime_permissions(); @@ -3438,7 +3371,7 @@ impl Config { agent_max_threads, agent_max_depth, agent_roles, - memories: cfg.memories.unwrap_or_default().into(), + memories: memories_config, agent_job_max_runtime_seconds, agent_interrupt_message_enabled, codex_home, diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index cce2491679..3733d78c38 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -31,13 +31,13 @@ use std::sync::Arc; use tempfile::tempdir; fn annotations(destructive_hint: Option, open_world_hint: Option) -> ToolAnnotations { - ToolAnnotations { + ToolAnnotations::from_raw( + /*title*/ None, + /*read_only_hint*/ None, destructive_hint, - idempotent_hint: None, + /*idempotent_hint*/ None, open_world_hint, - read_only_hint: None, - title: None, - } + ) } fn app(id: &str) -> AppInfo { @@ -63,17 +63,7 @@ fn plugin_names(names: &[&str]) -> Vec { } fn test_tool_definition(tool_name: &str) -> Tool { - Tool { - name: tool_name.to_string().into(), - title: None, - description: None, - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - } + Tool::new_with_raw(tool_name.to_string(), None, Arc::new(JsonObject::default())) } fn codex_app_tool( @@ -243,17 +233,11 @@ fn accessible_connectors_from_mcp_tools_preserves_description() { callable_name: "calendar_create_event".to_string(), callable_namespace: "mcp__codex_apps__calendar".to_string(), namespace_description: Some("Plan events".to_string()), - tool: Tool { - name: "calendar_create_event".to_string().into(), - title: None, - description: Some("Create a calendar event".into()), - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + tool: Tool::new( + "calendar_create_event", + "Create a calendar event", + Arc::new(JsonObject::default()), + ), connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), plugin_display_names: Vec::new(), diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index a3052b6642..051147b7cd 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -1,9 +1,17 @@ use crate::session::turn_context::TurnContext; use crate::session::turn_context::TurnEnvironment; use crate::shell::Shell; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashSet; +use std::path::PathBuf; use super::ContextualUserFragment; @@ -13,6 +21,7 @@ pub(crate) struct EnvironmentContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) network: Option, + pub(crate) filesystem: Option, pub(crate) subagents: Option, } @@ -83,6 +92,208 @@ impl EnvironmentContextEnvironments { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FileSystemContext { + workspace_roots: Vec, + permission_profile: FileSystemPermissionProfileContext, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum FileSystemPermissionProfileContext { + Managed(ManagedFileSystemContext), + Disabled, + External, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ManagedFileSystemContext { + Restricted { + entries: Vec, + glob_scan_max_depth: Option, + }, + Unrestricted, +} + +impl FileSystemContext { + fn from_permission_profile( + permission_profile: &PermissionProfile, + workspace_roots: &[AbsolutePathBuf], + ) -> Self { + let permission_profile = permission_profile + .clone() + .materialize_project_roots_with_workspace_roots(workspace_roots); + let workspace_roots = workspace_roots + .iter() + .map(|root| root.to_string_lossy().into_owned()) + .collect(); + let permission_profile = match permission_profile { + PermissionProfile::Managed { file_system, .. } => { + FileSystemPermissionProfileContext::Managed(ManagedFileSystemContext::from( + file_system, + )) + } + PermissionProfile::Disabled => FileSystemPermissionProfileContext::Disabled, + PermissionProfile::External { .. } => FileSystemPermissionProfileContext::External, + }; + Self { + workspace_roots, + permission_profile, + } + } + + fn render(&self) -> String { + let mut rendered = "".to_string(); + if !self.workspace_roots.is_empty() { + rendered.push_str(""); + for root in &self.workspace_roots { + push_text_element(&mut rendered, "root", root); + } + rendered.push_str(""); + } + self.permission_profile.render(&mut rendered); + rendered.push_str(""); + rendered + } +} + +impl From for ManagedFileSystemContext { + fn from(file_system: ManagedFileSystemPermissions) -> Self { + match file_system { + ManagedFileSystemPermissions::Restricted { + mut entries, + glob_scan_max_depth, + } => { + dedupe_file_system_entries(&mut entries); + Self::Restricted { + entries, + glob_scan_max_depth: glob_scan_max_depth.map(usize::from), + } + } + ManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, + } + } +} + +impl FileSystemPermissionProfileContext { + fn render(&self, rendered: &mut String) { + match self { + Self::Managed(file_system) => { + rendered.push_str(""); + file_system.render(rendered); + rendered.push_str(""); + } + Self::Disabled => { + rendered.push_str( + "", + ); + } + Self::External => { + rendered.push_str( + "", + ); + } + } + } +} + +impl ManagedFileSystemContext { + fn render(&self, rendered: &mut String) { + match self { + Self::Restricted { + entries, + glob_scan_max_depth, + } => { + if entries.is_empty() && glob_scan_max_depth.is_none() { + rendered.push_str(""); + return; + } + + rendered.push_str("'); + for entry in entries { + render_file_system_entry(rendered, entry); + } + rendered.push_str(""); + } + Self::Unrestricted => { + rendered.push_str(""); + } + } + } +} + +fn render_file_system_entry(rendered: &mut String, entry: &FileSystemSandboxEntry) { + rendered.push_str(""); + match &entry.path { + FileSystemPath::Path { path } => { + push_text_element(rendered, "path", path.to_string_lossy().as_ref()); + } + FileSystemPath::GlobPattern { pattern } => { + push_text_element(rendered, "glob", pattern); + } + FileSystemPath::Special { value } => { + let value = render_special_path(value); + push_text_element(rendered, "special", &value); + } + } + rendered.push_str(""); +} + +fn render_special_path(value: &FileSystemSpecialPath) -> String { + match value { + FileSystemSpecialPath::Root => ":root".to_string(), + FileSystemSpecialPath::Minimal => ":minimal".to_string(), + FileSystemSpecialPath::ProjectRoots { subpath } => { + render_special_path_with_subpath(":workspace_roots", subpath) + } + FileSystemSpecialPath::Tmpdir => ":tmpdir".to_string(), + FileSystemSpecialPath::SlashTmp => ":slash_tmp".to_string(), + FileSystemSpecialPath::Unknown { path, subpath } => { + render_special_path_with_subpath(path, subpath) + } + } +} + +fn render_special_path_with_subpath(base: &str, subpath: &Option) -> String { + match subpath { + Some(subpath) => format!("{base}/{}", subpath.display()), + None => base.to_string(), + } +} + +fn dedupe_file_system_entries(entries: &mut Vec) { + let mut seen = HashSet::new(); + entries.retain(|entry| seen.insert(entry.clone())); +} + +fn push_text_element(rendered: &mut String, name: &str, value: &str) { + rendered.push_str(&format!("<{name}>")); + push_xml_escaped_text(rendered, value); + rendered.push_str(&format!("")); +} + +fn push_xml_escaped_text(rendered: &mut String, value: &str) { + for ch in value.chars() { + match ch { + '&' => rendered.push_str("&"), + '<' => rendered.push_str("<"), + '>' => rendered.push_str(">"), + '"' => rendered.push_str("""), + '\'' => rendered.push_str("'"), + _ => rendered.push(ch), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub(crate) struct NetworkContext { allowed_domains: Vec, @@ -129,6 +340,7 @@ impl EnvironmentContext { current_date, timezone, network, + filesystem: None, subagents, } } @@ -138,6 +350,7 @@ impl EnvironmentContext { current_date: Option, timezone: Option, network: Option, + filesystem: Option, subagents: Option, ) -> Self { Self { @@ -145,6 +358,7 @@ impl EnvironmentContext { current_date, timezone, network, + filesystem, subagents, } } @@ -157,6 +371,7 @@ impl EnvironmentContext { && self.current_date == other.current_date && self.timezone == other.timezone && self.network == other.network + && self.filesystem == other.filesystem && self.subagents == other.subagents } @@ -165,6 +380,7 @@ impl EnvironmentContext { after: &EnvironmentContext, ) -> Self { let before_network = Self::network_from_turn_context_item(before); + let before_filesystem = Self::filesystem_from_turn_context_item(before); let environments = match &after.environments { EnvironmentContextEnvironments::Single(environment) => { if before.cwd.as_path() != environment.cwd.as_path() { @@ -186,17 +402,23 @@ impl EnvironmentContext { } else { before_network }; + let filesystem = if before_filesystem != after.filesystem { + after.filesystem.clone() + } else { + before_filesystem + }; EnvironmentContext::new_with_environments( environments, after.current_date.clone(), after.timezone.clone(), network, + filesystem, /*subagents*/ None, ) } pub(crate) fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { - Self::new( + let mut context = Self::new( EnvironmentContextEnvironment::from_turn_environments( &turn_context.environments.turn_environments, shell, @@ -205,7 +427,12 @@ impl EnvironmentContext { turn_context.timezone.clone(), Self::network_from_turn_context(turn_context), /*subagents*/ None, - ) + ); + context.filesystem = Some(FileSystemContext::from_permission_profile( + &turn_context.permission_profile, + &turn_context.config.effective_workspace_roots(), + )); + context } pub(crate) fn from_turn_context_item( @@ -216,11 +443,14 @@ impl EnvironmentContext { Ok(cwd) => cwd, Err(_) => AbsolutePathBuf::resolve_path_against_base(&turn_context_item.cwd, "/"), }; - Self::new( - vec![EnvironmentContextEnvironment::legacy(cwd, shell)], + Self::new_with_environments( + EnvironmentContextEnvironments::from_vec(vec![EnvironmentContextEnvironment::legacy( + cwd, shell, + )]), turn_context_item.current_date.clone(), turn_context_item.timezone.clone(), Self::network_from_turn_context_item(turn_context_item), + Self::filesystem_from_turn_context_item(turn_context_item), /*subagents*/ None, ) } @@ -266,6 +496,30 @@ impl EnvironmentContext { denied_domains.clone(), )) } + + fn filesystem_from_turn_context_item( + turn_context_item: &TurnContextItem, + ) -> Option { + Some(FileSystemContext::from_permission_profile( + &turn_context_item.permission_profile(), + &workspace_roots_from_turn_context_item(turn_context_item), + )) + } +} + +fn workspace_roots_from_turn_context_item( + turn_context_item: &TurnContextItem, +) -> Vec { + if let Some(workspace_roots) = turn_context_item.workspace_roots.as_ref() { + return workspace_roots.clone(); + } + + // Older rollout items did not persist workspace roots. Fall back to the + // legacy cwd binding only when reconstructing that historical context. + match AbsolutePathBuf::try_from(turn_context_item.cwd.clone()) { + Ok(cwd) => vec![cwd], + Err(_) => Vec::new(), + } } impl ContextualUserFragment for EnvironmentContext { @@ -324,6 +578,9 @@ impl ContextualUserFragment for EnvironmentContext { // lines.push(" ".to_string()); } } + if let Some(filesystem) = &self.filesystem { + lines.push(format!(" {}", filesystem.render())); + } if let Some(subagents) = &self.subagents { lines.push(" ".to_string()); lines.extend(subagents.lines().map(|line| format!(" {line}"))); diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/environment_context_tests.rs index 68ff7c9d44..875ce20c09 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/environment_context_tests.rs @@ -1,9 +1,21 @@ use crate::shell::ShellType; use super::*; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::permissions::project_roots_glob_pattern; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TurnContextItem; use codex_utils_absolute_path::test_support::PathBufExt; use core_test_support::test_path_buf; use pretty_assertions::assert_eq; +use std::path::Path; use std::path::PathBuf; fn fake_shell_name() -> String { @@ -79,6 +91,125 @@ fn serialize_environment_context_with_network() { assert_eq!(context.render(), expected); } +fn workspace_write_permission_profile_with_private_denials() -> PermissionProfile { + PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(Some(PathBuf::from("private"))), + }, + access: FileSystemAccessMode::Deny, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: project_roots_glob_pattern(Path::new("private/**")), + }, + access: FileSystemAccessMode::Deny, + }, + ]), + NetworkSandboxPolicy::Restricted, + ) +} + +#[test] +fn serialize_environment_context_with_full_filesystem_profile() { + let repo = test_abs_path("/repo"); + let other_repo = test_abs_path("/other-repo"); + let repo_private = repo.join("private"); + let other_repo_private = other_repo.join("private"); + let repo_private_glob = + AbsolutePathBuf::resolve_path_against_base(Path::new("private/**"), repo.as_path()); + let other_repo_private_glob = + AbsolutePathBuf::resolve_path_against_base(Path::new("private/**"), other_repo.as_path()); + let mut context = EnvironmentContext::new( + vec![EnvironmentContextEnvironment { + id: "local".to_string(), + cwd: test_path_buf("/repo").abs(), + shell: fake_shell_name(), + }], + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, + ); + context.filesystem = Some(FileSystemContext::from_permission_profile( + &workspace_write_permission_profile_with_private_denials(), + &[repo.clone(), other_repo.clone()], + )); + + let expected = format!( + r#" + {} + bash + {repo}{other_repo}{repo}{other_repo}{repo_private}{other_repo_private}{repo_private_glob}{other_repo_private_glob} +"#, + test_path_buf("/repo").display(), + repo = repo.to_string_lossy(), + other_repo = other_repo.to_string_lossy(), + repo_private = repo_private.to_string_lossy(), + other_repo_private = other_repo_private.to_string_lossy(), + repo_private_glob = repo_private_glob.to_string_lossy(), + other_repo_private_glob = other_repo_private_glob.to_string_lossy(), + ); + + assert_eq!(context.render(), expected); +} + +#[test] +fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() { + let repo = test_abs_path("/repo"); + let other_repo = test_abs_path("/other-repo"); + let repo_private = repo.join("private"); + let item = TurnContextItem { + turn_id: None, + cwd: test_path_buf("/not-the-workspace"), + workspace_roots: Some(vec![repo.clone(), other_repo.clone()]), + current_date: None, + timezone: None, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: Some(workspace_write_permission_profile_with_private_denials()), + network: None, + file_system_sandbox_policy: None, + model: "gpt-5".to_string(), + personality: None, + collaboration_mode: None, + realtime_active: None, + effort: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, + }; + + let context = EnvironmentContext::from_turn_context_item(&item, fake_shell_name()).render(); + + assert!( + context.contains(&format!( + "{}{}", + repo.to_string_lossy(), + other_repo.to_string_lossy() + )), + "{context}" + ); + assert!( + context.contains(&format!("{}", repo_private.to_string_lossy())), + "{context}" + ); + assert!( + !context.contains( + test_abs_path("/not-the-workspace") + .join("private") + .to_string_lossy() + .as_ref() + ), + "{context}" + ); +} + #[test] fn serialize_read_only_environment_context() { let context = EnvironmentContext::new( diff --git a/codex-rs/core/src/context/permissions_instructions.rs b/codex-rs/core/src/context/permissions_instructions.rs index db8f983c16..e87dceec01 100644 --- a/codex-rs/core/src/context/permissions_instructions.rs +++ b/codex-rs/core/src/context/permissions_instructions.rs @@ -4,6 +4,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::SandboxMode; use codex_protocol::models::PermissionProfile; use codex_protocol::models::format_allow_prefixes; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GranularApprovalConfig; @@ -68,9 +69,11 @@ impl PermissionsInstructions { exec_permission_approvals_enabled: bool, request_permissions_tool_enabled: bool, ) -> Self { - let (sandbox_mode, writable_roots) = sandbox_prompt_from_profile(permission_profile, cwd); + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); + let (sandbox_mode, writable_roots) = + sandbox_prompt_from_policy(&file_system_sandbox_policy, cwd); - Self::from_permissions_with_network( + Self::from_permissions_with_network_and_denied_reads( sandbox_mode, network_access_from_policy(permission_profile.network_sandbox_policy()), PermissionsPromptConfig { @@ -81,14 +84,32 @@ impl PermissionsInstructions { request_permissions_tool_enabled, }, writable_roots, + denied_reads_text(&file_system_sandbox_policy, cwd), ) } + #[cfg(test)] fn from_permissions_with_network( sandbox_mode: SandboxMode, network_access: NetworkAccess, config: PermissionsPromptConfig<'_>, writable_roots: Option>, + ) -> Self { + Self::from_permissions_with_network_and_denied_reads( + sandbox_mode, + network_access, + config, + writable_roots, + /*denied_reads*/ None, + ) + } + + fn from_permissions_with_network_and_denied_reads( + sandbox_mode: SandboxMode, + network_access: NetworkAccess, + config: PermissionsPromptConfig<'_>, + writable_roots: Option>, + denied_reads: Option, ) -> Self { let mut text = String::new(); append_section(&mut text, &sandbox_text(sandbox_mode, network_access)); @@ -105,6 +126,9 @@ impl PermissionsInstructions { if let Some(writable_roots) = writable_roots_text(writable_roots) { append_section(&mut text, &writable_roots); } + if let Some(denied_reads) = denied_reads { + append_section(&mut text, &denied_reads); + } if !text.ends_with('\n') { text.push('\n'); } @@ -112,27 +136,19 @@ impl PermissionsInstructions { } } -fn sandbox_prompt_from_profile( - permission_profile: &PermissionProfile, +fn sandbox_prompt_from_policy( + file_system_policy: &FileSystemSandboxPolicy, cwd: &Path, ) -> (SandboxMode, Option>) { - match permission_profile { - PermissionProfile::Disabled | PermissionProfile::External { .. } => { - (SandboxMode::DangerFullAccess, None) - } - PermissionProfile::Managed { .. } => { - let file_system_policy = permission_profile.file_system_sandbox_policy(); - if file_system_policy.has_full_disk_write_access() { - return (SandboxMode::DangerFullAccess, None); - } + if file_system_policy.has_full_disk_write_access() { + return (SandboxMode::DangerFullAccess, None); + } - let writable_roots = file_system_policy.get_writable_roots_with_cwd(cwd); - if writable_roots.is_empty() { - (SandboxMode::ReadOnly, None) - } else { - (SandboxMode::WorkspaceWrite, Some(writable_roots)) - } - } + let writable_roots = file_system_policy.get_writable_roots_with_cwd(cwd); + if writable_roots.is_empty() { + (SandboxMode::ReadOnly, None) + } else { + (SandboxMode::WorkspaceWrite, Some(writable_roots)) } } @@ -254,6 +270,28 @@ fn writable_roots_text(writable_roots: Option>) -> Option Option { + let mut entries = file_system_policy + .get_unreadable_roots_with_cwd(cwd) + .into_iter() + .map(|root| format!("- path `{}`", root.to_string_lossy())) + .collect::>(); + entries.extend( + file_system_policy + .get_unreadable_globs_with_cwd(cwd) + .into_iter() + .map(|glob| format!("- glob `{glob}`")), + ); + if entries.is_empty() { + return None; + } + + Some(format!( + "## Denied filesystem reads\nThe active permission profile denies reading these paths/globs. Do not request escalation or additional permissions to read them; these denials are policy restrictions.\n{}", + entries.join("\n") + )) +} + fn approved_command_prefixes_text(exec_policy: &Policy) -> Option { format_allow_prefixes(exec_policy.get_allowed_prefixes()) .filter(|prefixes| !prefixes.is_empty()) diff --git a/codex-rs/core/src/context/permissions_instructions_tests.rs b/codex-rs/core/src/context/permissions_instructions_tests.rs index 6d1aa5d886..580e9781ce 100644 --- a/codex-rs/core/src/context/permissions_instructions_tests.rs +++ b/codex-rs/core/src/context/permissions_instructions_tests.rs @@ -6,6 +6,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -83,6 +84,52 @@ fn builds_permissions_from_profile() { assert!(text.contains(writable_root.to_string_lossy().as_ref())); } +#[test] +fn builds_permissions_from_profile_with_denied_reads() { + let cwd = test_path_buf("/tmp"); + let denied_root = + AbsolutePathBuf::from_absolute_path(cwd.join("blocked")).expect("absolute path"); + let denied_glob = cwd.join("blocked").join("**"); + let permission_profile = PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: denied_root.clone(), + }, + access: FileSystemAccessMode::Deny, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: denied_glob.to_string_lossy().into_owned(), + }, + access: FileSystemAccessMode::Deny, + }, + ]), + NetworkSandboxPolicy::Restricted, + ); + + let instructions = PermissionsInstructions::from_permission_profile( + &permission_profile, + AskForApproval::OnRequest, + ApprovalsReviewer::AutoReview, + &Policy::empty(), + &cwd, + /*exec_permission_approvals_enabled*/ false, + /*request_permissions_tool_enabled*/ false, + ); + let text = instructions.body(); + assert!(text.contains("## Denied filesystem reads")); + assert!(text.contains("Do not request escalation or additional permissions")); + assert!(text.contains(denied_root.to_string_lossy().as_ref())); + assert!(text.contains(&format!("glob `{}`", denied_glob.to_string_lossy()))); +} + #[test] fn includes_request_rule_instructions_for_on_request() { let mut exec_policy = Policy::empty(); diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index f63ae6ec1f..42179c42b5 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -121,6 +121,7 @@ fn reference_context_item() -> TurnContextItem { TurnContextItem { turn_id: Some("reference-turn".to_string()), cwd: PathBuf::from("/tmp/reference-cwd"), + workspace_roots: None, current_date: Some("2026-03-23".to_string()), timezone: Some("America/Los_Angeles".to_string()), approval_policy: AskForApproval::OnRequest, diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 84ebe337c6..c1e3ed5d50 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -56,6 +56,7 @@ const SIGKILL_CODE: i32 = 9; const TIMEOUT_CODE: i32 = 64; const EXIT_CODE_SIGNAL_BASE: i32 = 128; // conventional shell: 128 + signal const EXEC_TIMEOUT_EXIT_CODE: i32 = 124; // conventional timeout exit code +const CANCELLATION_TERMINATION_GRACE_PERIOD: Duration = Duration::from_millis(50); // I/O buffer sizing const READ_CHUNK_SIZE: usize = 8192; // bytes per read @@ -224,6 +225,17 @@ impl ExecExpiration { } } + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn cancellation_token(&self) -> Option { + match self { + ExecExpiration::Timeout(_) | ExecExpiration::DefaultTimeout => None, + ExecExpiration::Cancellation(cancellation) + | ExecExpiration::TimeoutOrCancellation { cancellation, .. } => { + Some(cancellation.clone()) + } + } + } + pub(crate) fn with_cancellation(self, cancellation: CancellationToken) -> Self { match self { ExecExpiration::Timeout(timeout) => ExecExpiration::TimeoutOrCancellation { @@ -423,7 +435,6 @@ pub(crate) async fn execute_exec_request( stdout_stream: Option, after_spawn: Option>, ) -> Result { - let sandbox_policy = exec_request.compatibility_sandbox_policy(); let ExecRequest { command, cwd, @@ -464,7 +475,6 @@ pub(crate) async fn execute_exec_request( stdout_stream, after_spawn, sandbox, - &sandbox_policy, &permission_profile, &windows_sandbox_policy_cwd, windows_sandbox_filesystem_overrides.as_ref(), @@ -481,7 +491,6 @@ async fn get_raw_output_result( stdout_stream: Option, after_spawn: Option>, #[cfg_attr(not(windows), allow(unused_variables))] sandbox: SandboxType, - #[cfg_attr(not(windows), allow(unused_variables))] sandbox_policy: &SandboxPolicy, #[cfg_attr(not(windows), allow(unused_variables))] permission_profile: &PermissionProfile, #[cfg_attr(not(windows), allow(unused_variables))] windows_sandbox_policy_cwd: &AbsolutePathBuf, #[cfg_attr(not(windows), allow(unused_variables))] windows_sandbox_filesystem_overrides: Option< @@ -492,7 +501,6 @@ async fn get_raw_output_result( if sandbox == SandboxType::WindowsRestrictedToken { return exec_windows_sandbox( params, - sandbox_policy, permission_profile, windows_sandbox_policy_cwd, windows_sandbox_filesystem_overrides, @@ -572,7 +580,6 @@ fn record_windows_sandbox_spawn_failure( #[cfg(target_os = "windows")] async fn exec_windows_sandbox( params: ExecParams, - sandbox_policy: &SandboxPolicy, permission_profile: &PermissionProfile, windows_sandbox_policy_cwd: &AbsolutePathBuf, windows_sandbox_filesystem_overrides: Option<&WindowsSandboxFilesystemOverrides>, @@ -596,19 +603,18 @@ async fn exec_windows_sandbox( network.apply_to_env(&mut env); } - // TODO(iceweasel-oai): run_windows_sandbox_capture should support all - // variants of ExecExpiration, not just timeout. - let timeout_ms = if capture_policy.uses_expiration() { - expiration.timeout_ms() + // Windows sandbox capture still receives timeout and cancellation separately. + let (cancellation, timeout_ms) = if capture_policy.uses_expiration() { + let cancellation = expiration.cancellation_token().map(|token| { + codex_windows_sandbox::WindowsSandboxCancellationToken::new(move || { + token.is_cancelled() + }) + }); + (cancellation, expiration.timeout_ms()) } else { - None + (None, None) }; - let policy_str = serde_json::to_string(sandbox_policy).map_err(|err| { - CodexErr::Io(io::Error::other(format!( - "failed to serialize Windows sandbox policy: {err}" - ))) - })?; let sandbox_cwd = windows_sandbox_policy_cwd.clone(); let permission_profile = permission_profile.clone(); let codex_home = find_codex_home().map_err(|err| { @@ -643,6 +649,7 @@ async fn exec_windows_sandbox( cwd: &cwd, env_map: env, timeout_ms, + cancellation, use_private_desktop: windows_sandbox_private_desktop, proxy_enforced, read_roots_override: elevated_read_roots_override.as_deref(), @@ -655,13 +662,14 @@ async fn exec_windows_sandbox( ) } else { run_windows_sandbox_capture_with_filesystem_overrides( - policy_str.as_str(), + &permission_profile, &sandbox_cwd, codex_home.as_ref(), command, &cwd, env, timeout_ms, + cancellation, &additional_deny_read_paths, &additional_deny_write_paths, windows_sandbox_private_desktop, @@ -1368,15 +1376,49 @@ async fn consume_output( (exit_status, false) } outcome = &mut expiration_wait => { - kill_child_process_group(&mut child)?; - child.start_kill()?; - let timed_out = matches!(outcome, Some(ExecExpirationOutcome::TimedOut)); - let exit_status = if timed_out { - synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE) - } else { - synthetic_exit_status_for_code(/*code*/ 1) - }; - (exit_status, timed_out) + match outcome { + Some(ExecExpirationOutcome::TimedOut) => { + kill_child_process_group(&mut child)?; + child.start_kill()?; + ( + synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE), + true, + ) + } + Some(ExecExpirationOutcome::Cancelled) => { + // Let TERM-aware processes run cleanup briefly, then kill any + // remaining members of the original process group. + let process_group_id = child.id(); + let should_escalate = if let Some(process_group_id) = process_group_id { + codex_utils_pty::process_group::terminate_process_group(process_group_id)? + } else { + false + }; + match tokio::time::timeout( + CANCELLATION_TERMINATION_GRACE_PERIOD, + child.wait(), + ) + .await + { + Ok(status) => { + status?; + if should_escalate + && let Some(process_group_id) = process_group_id + { + codex_utils_pty::process_group::kill_process_group( + process_group_id, + )?; + } + } + Err(_) => { + kill_child_process_group(&mut child)?; + child.start_kill()?; + } + } + (synthetic_exit_status_for_code(/*code*/ 1), false) + } + None => unreachable!("expiration wait only resolves while expiration is active"), + } } _ = tokio::signal::ctrl_c() => { kill_child_process_group(&mut child)?; diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 23db019bb9..504bac622e 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -1128,6 +1128,115 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> { Ok(()) } +#[cfg(unix)] +#[tokio::test] +async fn process_exec_tool_call_cancellation_allows_sigterm_cleanup() -> Result<()> { + let temp_dir = tempfile::TempDir::new()?; + let ready_marker = temp_dir.path().join("ready"); + let cleanup_marker = temp_dir.path().join("cleanup"); + let descendant_pid_marker = temp_dir.path().join("descendant-pid"); + // The parent handles TERM and records cleanup, while a TERM-ignoring child + // proves cancellation still escalates any survivors in the process group. + let command = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + r#"(trap '' TERM; sleep 60) & +printf '%s' "$!" > "$DESCENDANT_PID_MARKER" +trap 'printf cleaned > "$CLEANUP_MARKER"; exit 0' TERM +printf ready > "$READY_MARKER" +while :; do sleep 1; done"# + .to_string(), + ]; + let cwd = codex_utils_absolute_path::AbsolutePathBuf::current_dir()?; + let mut env: HashMap = std::env::vars().collect(); + env.insert( + "READY_MARKER".to_string(), + ready_marker.to_string_lossy().into_owned(), + ); + env.insert( + "CLEANUP_MARKER".to_string(), + cleanup_marker.to_string_lossy().into_owned(), + ); + env.insert( + "DESCENDANT_PID_MARKER".to_string(), + descendant_pid_marker.to_string_lossy().into_owned(), + ); + let cancel_token = CancellationToken::new(); + let cancel_tx = cancel_token.clone(); + tokio::spawn(async move { + for _ in 0..50 { + if ready_marker.exists() { + cancel_tx.cancel(); + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + cancel_tx.cancel(); + }); + let params = ExecParams { + command, + cwd: cwd.clone(), + expiration: ExecExpiration::DefaultTimeout.with_cancellation(cancel_token), + capture_policy: ExecCapturePolicy::ShellTool, + env, + network: None, + sandbox_permissions: SandboxPermissions::UseDefault, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + justification: None, + arg0: None, + }; + + let result = timeout( + Duration::from_secs(5), + process_exec_tool_call( + params, + &PermissionProfile::Disabled, + &cwd, + &None, + /*use_legacy_landlock*/ false, + /*stdout_stream*/ None, + ), + ) + .await + .expect("cancellation should stop the process promptly"); + let output = result.expect("cancellation should return a non-timeout exec result"); + assert!(!output.timed_out); + assert_eq!( + std::fs::read_to_string(cleanup_marker)?, + "cleaned", + "SIGTERM cleanup trap should run before cancellation falls back to a hard kill" + ); + let descendant_pid = std::fs::read_to_string(descendant_pid_marker)? + .parse::() + .map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("failed to parse descendant pid: {error}"), + ) + })?; + let mut killed = false; + for _ in 0..20 { + if unsafe { libc::kill(descendant_pid, 0) } == -1 + && let Some(libc::ESRCH) = std::io::Error::last_os_error().raw_os_error() + { + killed = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + if !killed { + unsafe { + libc::kill(descendant_pid, libc::SIGKILL); + } + } + assert!( + killed, + "TERM-ignoring descendant process with pid {descendant_pid} is still alive" + ); + Ok(()) +} + #[cfg(unix)] fn long_running_command() -> Vec { vec![ diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 6df681d4b0..ece020385a 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -26,7 +26,7 @@ use codex_otel::GOAL_TOKEN_COUNT_METRIC; use codex_otel::GOAL_USAGE_LIMITED_METRIC; use codex_protocol::ThreadId; use codex_protocol::config_types::ModeKind; -use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; @@ -172,13 +172,12 @@ pub(crate) struct GoalRuntimeState { pub(crate) budget_limit_reported_goal_id: Mutex>, accounting_lock: Semaphore, accounting: Mutex, - continuation_turn_id: Mutex>, pub(crate) continuation_lock: Semaphore, } struct GoalContinuationCandidate { goal_id: String, - items: Vec, + items: Vec, } impl GoalRuntimeState { @@ -188,7 +187,6 @@ impl GoalRuntimeState { budget_limit_reported_goal_id: Mutex::new(None), accounting_lock: Semaphore::new(/*permits*/ 1), accounting: Mutex::new(GoalAccountingSnapshot::new()), - continuation_turn_id: Mutex::new(None), continuation_lock: Semaphore::new(/*permits*/ 1), } } @@ -688,7 +686,7 @@ impl Session { .await; if let Some(goal) = goal_for_steering { let item = goal_context_input_item(objective_updated_prompt(&goal)); - if self.inject_response_items(vec![item]).await.is_err() { + if self.inject_if_running(vec![item]).await.is_err() { tracing::debug!( "skipping objective-updated goal steering because no turn is active" ); @@ -900,20 +898,6 @@ impl Session { } } - async fn mark_thread_goal_continuation_turn_started(&self, turn_id: String) { - *self.goal_runtime.continuation_turn_id.lock().await = Some(turn_id); - } - - async fn take_thread_goal_continuation_turn(&self, turn_id: &str) -> bool { - let mut continuation_turn_id = self.goal_runtime.continuation_turn_id.lock().await; - if continuation_turn_id.as_deref() == Some(turn_id) { - *continuation_turn_id = None; - true - } else { - false - } - } - async fn clear_reserved_goal_continuation_turn(&self, turn_state: &Arc>) { let mut active_turn_guard = self.active_turn.lock().await; if let Some(active_turn) = active_turn_guard.as_ref() @@ -941,8 +925,6 @@ impl Session { tracing::warn!("failed to account thread goal progress at turn end: {err}"); } - self.take_thread_goal_continuation_turn(&turn_context.sub_id) - .await; if turn_completed { let mut accounting = self.goal_runtime.accounting.lock().await; if accounting @@ -957,8 +939,6 @@ impl Session { async fn handle_thread_goal_task_abort(&self, turn_context: Option<&TurnContext>) { if let Some(turn_context) = turn_context { - self.take_thread_goal_continuation_turn(&turn_context.sub_id) - .await; if let Err(err) = self .account_thread_goal_progress( turn_context, @@ -1094,7 +1074,7 @@ impl Session { .await; if should_steer_budget_limit { let item = budget_limit_steering_item(&goal); - if self.inject_response_items(vec![item]).await.is_err() { + if self.inject_if_running(vec![item]).await.is_err() { tracing::debug!("skipping budget-limit goal steering because no turn is active"); } *self.goal_runtime.budget_limit_reported_goal_id.lock().await = Some(goal_id); @@ -1352,7 +1332,7 @@ impl Session { candidate .items .into_iter() - .map(TurnInput::ResponseInputItem) + .map(TurnInput::ResponseItem) .collect(), ) .await; @@ -1373,8 +1353,6 @@ impl Session { .await; return; } - self.mark_thread_goal_continuation_turn_started(turn_context.sub_id.clone()) - .await; self.start_task(turn_context, Vec::new(), RegularTask::new()) .await; } @@ -1393,14 +1371,6 @@ impl Session { tracing::debug!("skipping active goal continuation because a turn is already active"); return None; } - if self - .input_queue - .has_queued_response_items_for_next_turn() - .await - { - tracing::debug!("skipping active goal continuation because queued input exists"); - return None; - } if self.input_queue.has_trigger_turn_mailbox_items().await { tracing::debug!( "skipping active goal continuation because trigger-turn mailbox input is pending" @@ -1438,10 +1408,6 @@ impl Session { return None; } if self.active_turn.lock().await.is_some() - || self - .input_queue - .has_queued_response_items_for_next_turn() - .await || self.input_queue.has_trigger_turn_mailbox_items().await { tracing::debug!("skipping active goal continuation because pending work appeared"); @@ -1626,12 +1592,12 @@ fn escape_xml_text(input: &str) -> String { .replace('>', ">") } -fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseInputItem { +fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseItem { goal_context_input_item(budget_limit_prompt(goal)) } -fn goal_context_input_item(prompt: String) -> ResponseInputItem { - GoalContext::new(prompt).into_response_input_item() +fn goal_context_input_item(prompt: String) -> ResponseItem { + ResponseItem::from(GoalContext::new(prompt).into_response_input_item()) } pub(crate) fn protocol_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { @@ -1700,7 +1666,7 @@ mod tests { use codex_protocol::ThreadId; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; - use codex_protocol::models::ResponseInputItem; + use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::TokenUsage; @@ -1829,7 +1795,8 @@ mod tests { assert_eq!( item, - ResponseInputItem::Message { + ResponseItem::Message { + id: None, role: "user".to_string(), content: vec![ContentItem::InputText { text: "\nContinue working.\n".to_string(), diff --git a/codex-rs/core/src/guardian/metrics.rs b/codex-rs/core/src/guardian/metrics.rs new file mode 100644 index 0000000000..9b9d35a526 --- /dev/null +++ b/codex-rs/core/src/guardian/metrics.rs @@ -0,0 +1,418 @@ +use std::time::Duration; + +use codex_analytics::GuardianApprovalRequestSource; +use codex_analytics::GuardianReviewAnalyticsResult; +use codex_analytics::GuardianReviewDecision; +use codex_analytics::GuardianReviewFailureReason; +use codex_analytics::GuardianReviewSessionKind; +use codex_analytics::GuardianReviewTerminalStatus; +use codex_analytics::GuardianReviewedAction; +use codex_otel::GUARDIAN_REVIEW_COUNT_METRIC; +use codex_otel::GUARDIAN_REVIEW_DURATION_METRIC; +use codex_otel::GUARDIAN_REVIEW_TOKEN_USAGE_METRIC; +use codex_otel::GUARDIAN_REVIEW_TTFT_DURATION_METRIC; +use codex_otel::SessionTelemetry; +use codex_otel::sanitize_metric_tag_value; +use codex_protocol::protocol::GuardianAssessmentOutcome; +use codex_protocol::protocol::GuardianRiskLevel; +use codex_protocol::protocol::GuardianUserAuthorization; +use codex_protocol::protocol::TokenUsage; + +pub(crate) fn emit_guardian_review_metrics( + session_telemetry: &SessionTelemetry, + result: &GuardianReviewAnalyticsResult, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: &GuardianReviewedAction, + completion_latency_ms: u64, +) { + let tags = guardian_review_metric_tags(result, approval_request_source, reviewed_action); + let tag_refs: Vec<(&str, &str)> = tags + .iter() + .map(|(key, value)| (*key, value.as_str())) + .collect(); + + session_telemetry.counter(GUARDIAN_REVIEW_COUNT_METRIC, /*inc*/ 1, &tag_refs); + session_telemetry.record_duration( + GUARDIAN_REVIEW_DURATION_METRIC, + Duration::from_millis(completion_latency_ms), + &tag_refs, + ); + + if let Some(time_to_first_token_ms) = result.time_to_first_token_ms { + session_telemetry.record_duration( + GUARDIAN_REVIEW_TTFT_DURATION_METRIC, + Duration::from_millis(time_to_first_token_ms), + &tag_refs, + ); + } + + if let Some(token_usage) = result.token_usage.as_ref() { + emit_guardian_token_usage_histograms(session_telemetry, token_usage, tags); + } +} + +fn emit_guardian_token_usage_histograms( + session_telemetry: &SessionTelemetry, + token_usage: &TokenUsage, + base_tags: Vec<(&'static str, String)>, +) { + for (token_type, value) in [ + ("total", token_usage.total_tokens.max(0)), + ("input", token_usage.input_tokens.max(0)), + ("cached_input", token_usage.cached_input()), + ("non_cached_input", token_usage.non_cached_input()), + ("output", token_usage.output_tokens.max(0)), + ( + "reasoning_output", + token_usage.reasoning_output_tokens.max(0), + ), + ] { + let mut tags = base_tags.clone(); + tags.push(("token_type", token_type.to_string())); + let tag_refs: Vec<(&str, &str)> = tags + .iter() + .map(|(key, value)| (*key, value.as_str())) + .collect(); + session_telemetry.histogram(GUARDIAN_REVIEW_TOKEN_USAGE_METRIC, value, &tag_refs); + } +} + +fn guardian_review_metric_tags( + result: &GuardianReviewAnalyticsResult, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: &GuardianReviewedAction, +) -> Vec<(&'static str, String)> { + vec![ + ("decision", decision_tag(result.decision).to_string()), + ( + "terminal_status", + terminal_status_tag(result.terminal_status).to_string(), + ), + ( + "failure_reason", + failure_reason_tag(result.failure_reason).to_string(), + ), + ( + "approval_request_source", + approval_request_source_tag(approval_request_source).to_string(), + ), + ("action", reviewed_action_tag(reviewed_action).to_string()), + ( + "session_kind", + session_kind_tag(result.guardian_session_kind).to_string(), + ), + ( + "had_prior_review_context", + optional_bool_tag(result.had_prior_review_context).to_string(), + ), + ( + "reviewed_action_truncated", + bool_tag(result.reviewed_action_truncated).to_string(), + ), + ("risk_level", risk_level_tag(result.risk_level).to_string()), + ( + "user_authorization", + user_authorization_tag(result.user_authorization).to_string(), + ), + ("outcome", outcome_tag(result.outcome).to_string()), + ( + "guardian_model", + result + .guardian_model + .as_deref() + .map(sanitize_metric_tag_value) + .unwrap_or_else(|| "none".to_string()), + ), + ( + "guardian_reasoning_effort", + result + .guardian_reasoning_effort + .as_deref() + .map(sanitize_metric_tag_value) + .unwrap_or_else(|| "none".to_string()), + ), + ] +} + +fn decision_tag(decision: GuardianReviewDecision) -> &'static str { + match decision { + GuardianReviewDecision::Approved => "approved", + GuardianReviewDecision::Denied => "denied", + GuardianReviewDecision::Aborted => "aborted", + } +} + +fn terminal_status_tag(status: GuardianReviewTerminalStatus) -> &'static str { + match status { + GuardianReviewTerminalStatus::Approved => "approved", + GuardianReviewTerminalStatus::Denied => "denied", + GuardianReviewTerminalStatus::Aborted => "aborted", + GuardianReviewTerminalStatus::TimedOut => "timed_out", + GuardianReviewTerminalStatus::FailedClosed => "failed_closed", + } +} + +fn failure_reason_tag(reason: Option) -> &'static str { + match reason { + Some(GuardianReviewFailureReason::Timeout) => "timeout", + Some(GuardianReviewFailureReason::Cancelled) => "cancelled", + Some(GuardianReviewFailureReason::PromptBuildError) => "prompt_build_error", + Some(GuardianReviewFailureReason::SessionError) => "session_error", + Some(GuardianReviewFailureReason::ParseError) => "parse_error", + None => "none", + } +} + +fn approval_request_source_tag(source: GuardianApprovalRequestSource) -> &'static str { + match source { + GuardianApprovalRequestSource::MainTurn => "main_turn", + GuardianApprovalRequestSource::DelegatedSubagent => "delegated_subagent", + } +} + +fn reviewed_action_tag(action: &GuardianReviewedAction) -> &'static str { + match action { + GuardianReviewedAction::Shell { .. } => "shell", + GuardianReviewedAction::UnifiedExec { .. } => "unified_exec", + GuardianReviewedAction::Execve { .. } => "execve", + GuardianReviewedAction::ApplyPatch {} => "apply_patch", + GuardianReviewedAction::NetworkAccess { .. } => "network_access", + GuardianReviewedAction::McpToolCall { .. } => "mcp_tool_call", + GuardianReviewedAction::RequestPermissions {} => "request_permissions", + } +} + +fn session_kind_tag(kind: Option) -> &'static str { + match kind { + Some(GuardianReviewSessionKind::TrunkNew) => "trunk_new", + Some(GuardianReviewSessionKind::TrunkReused) => "trunk_reused", + Some(GuardianReviewSessionKind::EphemeralForked) => "ephemeral_forked", + None => "none", + } +} + +fn optional_bool_tag(value: Option) -> &'static str { + match value { + Some(true) => "true", + Some(false) => "false", + None => "unknown", + } +} + +fn bool_tag(value: bool) -> &'static str { + if value { "true" } else { "false" } +} + +fn risk_level_tag(risk_level: Option) -> &'static str { + match risk_level { + Some(GuardianRiskLevel::Low) => "low", + Some(GuardianRiskLevel::Medium) => "medium", + Some(GuardianRiskLevel::High) => "high", + Some(GuardianRiskLevel::Critical) => "critical", + None => "none", + } +} + +fn user_authorization_tag(user_authorization: Option) -> &'static str { + match user_authorization { + Some(GuardianUserAuthorization::Unknown) => "unknown", + Some(GuardianUserAuthorization::Low) => "low", + Some(GuardianUserAuthorization::Medium) => "medium", + Some(GuardianUserAuthorization::High) => "high", + None => "none", + } +} + +fn outcome_tag(outcome: Option) -> &'static str { + match outcome { + Some(GuardianAssessmentOutcome::Allow) => "allow", + Some(GuardianAssessmentOutcome::Deny) => "deny", + None => "none", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use codex_otel::MetricsClient; + use codex_otel::MetricsConfig; + use codex_protocol::ThreadId; + use codex_protocol::protocol::SessionSource; + use opentelemetry::KeyValue; + use opentelemetry_sdk::metrics::InMemoryMetricExporter; + use opentelemetry_sdk::metrics::data::AggregatedMetrics; + use opentelemetry_sdk::metrics::data::Metric; + use opentelemetry_sdk::metrics::data::MetricData; + use opentelemetry_sdk::metrics::data::ResourceMetrics; + use pretty_assertions::assert_eq; + use std::collections::BTreeMap; + + fn test_session_telemetry() -> SessionTelemetry { + let exporter = InMemoryMetricExporter::default(); + let metrics = MetricsClient::new( + MetricsConfig::in_memory("test", "codex-core", env!("CARGO_PKG_VERSION"), exporter) + .with_runtime_reader(), + ) + .expect("in-memory metrics client"); + SessionTelemetry::new( + ThreadId::new(), + "gpt-5.4", + "gpt-5.4", + /*account_id*/ None, + /*account_email*/ None, + /*auth_mode*/ None, + "test_originator".to_string(), + /*log_user_prompts*/ false, + "tty".to_string(), + SessionSource::Cli, + ) + .with_metrics_without_metadata_tags(metrics) + } + + fn find_metric<'a>(resource_metrics: &'a ResourceMetrics, name: &str) -> &'a Metric { + for scope_metrics in resource_metrics.scope_metrics() { + for metric in scope_metrics.metrics() { + if metric.name() == name { + return metric; + } + } + } + panic!("metric {name} missing"); + } + + fn attributes_to_map<'a>( + attributes: impl Iterator, + ) -> BTreeMap { + attributes + .map(|kv| (kv.key.as_str().to_string(), kv.value.as_str().to_string())) + .collect() + } + + fn counter_point( + resource_metrics: &ResourceMetrics, + name: &str, + ) -> (BTreeMap, u64) { + let metric = find_metric(resource_metrics, name); + match metric.data() { + AggregatedMetrics::U64(data) => match data { + MetricData::Sum(sum) => { + let points: Vec<_> = sum.data_points().collect(); + assert_eq!(points.len(), 1); + let point = points[0]; + (attributes_to_map(point.attributes()), point.value()) + } + _ => panic!("unexpected counter aggregation"), + }, + _ => panic!("unexpected counter data type"), + } + } + + fn histogram_sums(resource_metrics: &ResourceMetrics, name: &str) -> BTreeMap { + let metric = find_metric(resource_metrics, name); + match metric.data() { + AggregatedMetrics::F64(data) => match data { + MetricData::Histogram(histogram) => histogram + .data_points() + .map(|point| { + let attrs = attributes_to_map(point.attributes()); + ( + attrs + .get("token_type") + .cloned() + .unwrap_or_else(|| "sample".to_string()), + point.sum() as u64, + ) + }) + .collect(), + _ => panic!("unexpected histogram aggregation"), + }, + _ => panic!("unexpected histogram data type"), + } + } + + #[test] + fn guardian_review_metrics_record_counts_durations_and_token_usage() { + let session_telemetry = test_session_telemetry(); + let result = GuardianReviewAnalyticsResult { + decision: GuardianReviewDecision::Approved, + terminal_status: GuardianReviewTerminalStatus::Approved, + risk_level: Some(GuardianRiskLevel::Low), + user_authorization: Some(GuardianUserAuthorization::High), + outcome: Some(GuardianAssessmentOutcome::Allow), + guardian_session_kind: Some(GuardianReviewSessionKind::TrunkReused), + guardian_model: Some("gpt-5.4 guardian".to_string()), + guardian_reasoning_effort: Some("low".to_string()), + had_prior_review_context: Some(true), + reviewed_action_truncated: true, + token_usage: Some(TokenUsage { + input_tokens: 10, + cached_input_tokens: 4, + output_tokens: 3, + reasoning_output_tokens: 2, + total_tokens: 15, + }), + time_to_first_token_ms: Some(123), + ..GuardianReviewAnalyticsResult::without_session() + }; + + emit_guardian_review_metrics( + &session_telemetry, + &result, + GuardianApprovalRequestSource::DelegatedSubagent, + &GuardianReviewedAction::NetworkAccess { + protocol: codex_protocol::approvals::NetworkApprovalProtocol::Https, + port: 443, + }, + /*completion_latency_ms*/ 456, + ); + + let snapshot = session_telemetry + .snapshot_metrics() + .expect("runtime metrics snapshot"); + let (attrs, value) = counter_point(&snapshot, GUARDIAN_REVIEW_COUNT_METRIC); + + assert_eq!(value, 1); + assert_eq!( + attrs, + BTreeMap::from([ + ("action".to_string(), "network_access".to_string()), + ( + "approval_request_source".to_string(), + "delegated_subagent".to_string() + ), + ("decision".to_string(), "approved".to_string()), + ("failure_reason".to_string(), "none".to_string()), + ("guardian_model".to_string(), "gpt-5.4_guardian".to_string()), + ("guardian_reasoning_effort".to_string(), "low".to_string()), + ("had_prior_review_context".to_string(), "true".to_string()), + ("outcome".to_string(), "allow".to_string()), + ("reviewed_action_truncated".to_string(), "true".to_string()), + ("risk_level".to_string(), "low".to_string()), + ("session_kind".to_string(), "trunk_reused".to_string()), + ("terminal_status".to_string(), "approved".to_string()), + ("user_authorization".to_string(), "high".to_string()), + ]) + ); + + assert_eq!( + histogram_sums(&snapshot, GUARDIAN_REVIEW_TOKEN_USAGE_METRIC), + BTreeMap::from([ + ("cached_input".to_string(), 4), + ("input".to_string(), 10), + ("non_cached_input".to_string(), 6), + ("output".to_string(), 3), + ("reasoning_output".to_string(), 2), + ("total".to_string(), 15), + ]) + ); + assert_eq!( + histogram_sums(&snapshot, GUARDIAN_REVIEW_DURATION_METRIC), + BTreeMap::from([("sample".to_string(), 456)]) + ); + assert_eq!( + histogram_sums(&snapshot, GUARDIAN_REVIEW_TTFT_DURATION_METRIC), + BTreeMap::from([("sample".to_string(), 123)]) + ); + } +} diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 058c19d008..8cc04e5c42 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -12,6 +12,7 @@ //! 4. Apply the guardian's explicit allow/deny outcome. mod approval_request; +mod metrics; mod prompt; mod review; mod review_session; @@ -40,6 +41,7 @@ pub(crate) use review::review_approval_request_with_cancel; pub(crate) use review::routes_approval_to_guardian; pub(crate) use review::spawn_approval_request_review; pub(crate) use review_session::GuardianReviewSessionManager; +pub(crate) use review_session::prompt_cache_key_override_for_review_session; pub(crate) const GUARDIAN_REVIEW_TIMEOUT: Duration = Duration::from_secs(90); pub(crate) const GUARDIAN_REVIEWER_NAME: &str = "guardian"; @@ -147,6 +149,8 @@ use prompt::GuardianTranscriptEntryKind; #[cfg(test)] use prompt::build_guardian_prompt_items; #[cfg(test)] +use prompt::build_guardian_prompt_items_with_parent_turn; +#[cfg(test)] use prompt::collect_guardian_transcript_entries; #[cfg(test)] use prompt::guardian_output_schema; diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index b1b132a984..062a21deef 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -10,6 +10,7 @@ use serde_json::Value; use crate::compact::content_items_to_text; use crate::event_mapping::is_contextual_user_message_content; use crate::session::session::Session; +use crate::session::turn_context::TurnContext; use codex_utils_output_truncation::approx_bytes_for_tokens; use codex_utils_output_truncation::approx_token_count; use codex_utils_output_truncation::approx_tokens_from_byte_count; @@ -86,11 +87,29 @@ pub(crate) enum GuardianPromptMode { /// Split the variable request into separate user content items so the /// Responses request snapshot shows clear boundaries while preserving exact /// prompt text through trailing newlines. +#[cfg(test)] pub(crate) async fn build_guardian_prompt_items( session: &Session, retry_reason: Option, request: GuardianApprovalRequest, mode: GuardianPromptMode, +) -> serde_json::Result { + build_guardian_prompt_items_with_parent_turn( + session, + /*parent_turn*/ None, + retry_reason, + request, + mode, + ) + .await +} + +pub(crate) async fn build_guardian_prompt_items_with_parent_turn( + session: &Session, + parent_turn: Option<&TurnContext>, + retry_reason: Option, + request: GuardianApprovalRequest, + mode: GuardianPromptMode, ) -> serde_json::Result { let history = session.clone_history().await; let transcript_entries = collect_guardian_transcript_entries(history.raw_items()); @@ -172,6 +191,11 @@ pub(crate) async fn build_guardian_prompt_items( if let Some(note) = omission_note { push_text(format!("\n{note}\n")); } + if let Some(denied_reads_context) = parent_turn.and_then(parent_turn_denied_reads_context) { + push_text("\n>>> PARENT TURN PERMISSION CONTEXT START\n".to_string()); + push_text(denied_reads_context); + push_text(">>> PARENT TURN PERMISSION CONTEXT END\n".to_string()); + } match &request { GuardianApprovalRequest::NetworkAccess { trigger, .. } => { push_text(">>> APPROVAL REQUEST START\n".to_string()); @@ -216,6 +240,31 @@ pub(crate) async fn build_guardian_prompt_items( }) } +fn parent_turn_denied_reads_context(turn: &TurnContext) -> Option { + #[allow(deprecated)] + let cwd = &turn.cwd; + let file_system_policy = turn.permission_profile.file_system_sandbox_policy(); + let mut entries = file_system_policy + .get_unreadable_roots_with_cwd(cwd) + .into_iter() + .map(|root| format!("- path `{}`", root.to_string_lossy())) + .collect::>(); + entries.extend( + file_system_policy + .get_unreadable_globs_with_cwd(cwd) + .into_iter() + .map(|glob| format!("- glob `{glob}`")), + ); + if entries.is_empty() { + return None; + } + + Some(format!( + "The parent turn's active permission profile denies reading these paths/globs. These are policy restrictions; do not approve escalation whose purpose is to read them.\n{}\n", + entries.join("\n") + )) +} + enum GuardianPromptShape { Full, Delta { already_seen_entry_count: usize }, diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 7df7a96928..db4343448f 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -4,6 +4,7 @@ use codex_analytics::GuardianReviewDecision; use codex_analytics::GuardianReviewFailureReason; use codex_analytics::GuardianReviewTerminalStatus; use codex_analytics::GuardianReviewTrackContext; +use codex_analytics::GuardianReviewedAction; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -36,6 +37,7 @@ use super::approval_request::guardian_assessment_action; use super::approval_request::guardian_request_target_item_id; use super::approval_request::guardian_request_turn_id; use super::approval_request::guardian_reviewed_action; +use super::metrics::emit_guardian_review_metrics; use super::prompt::guardian_output_schema; use super::prompt::parse_guardian_assessment; use super::review_session::GuardianReviewSessionOutcome; @@ -162,9 +164,18 @@ pub(crate) fn is_guardian_reviewer_source( fn track_guardian_review( session: &Session, tracking: &GuardianReviewTrackContext, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: &GuardianReviewedAction, result: GuardianReviewAnalyticsResult, completed_at_ms: u64, ) { + emit_guardian_review_metrics( + &session.services.session_telemetry, + &result, + approval_request_source, + reviewed_action, + completed_at_ms.saturating_sub(tracking.started_at_ms), + ); session .services .analytics_events_client @@ -244,13 +255,14 @@ async fn run_guardian_review( let target_item_id = guardian_request_target_item_id(&request).map(str::to_string); let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string(); let action_summary = guardian_assessment_action(&request); + let reviewed_action = guardian_reviewed_action(&request); let review_tracking = GuardianReviewTrackContext::new( session.conversation_id.to_string(), assessment_turn_id.clone(), review_id.clone(), target_item_id.clone(), approval_request_source, - guardian_reviewed_action(&request), + reviewed_action.clone(), GUARDIAN_REVIEW_TIMEOUT.as_millis() as u64, ); let started_at_ms = review_tracking.started_at_ms.try_into().unwrap_or_default(); @@ -281,6 +293,8 @@ async fn run_guardian_review( track_guardian_review( session.as_ref(), &review_tracking, + approval_request_source, + &reviewed_action, GuardianReviewAnalyticsResult { decision: GuardianReviewDecision::Aborted, terminal_status: GuardianReviewTerminalStatus::Aborted, @@ -330,6 +344,8 @@ async fn run_guardian_review( track_guardian_review( session.as_ref(), &review_tracking, + approval_request_source, + &reviewed_action, GuardianReviewAnalyticsResult { decision: if approved { GuardianReviewDecision::Approved @@ -361,6 +377,8 @@ async fn run_guardian_review( track_guardian_review( session.as_ref(), &review_tracking, + approval_request_source, + &reviewed_action, GuardianReviewAnalyticsResult { decision: GuardianReviewDecision::Denied, terminal_status: GuardianReviewTerminalStatus::TimedOut, @@ -402,6 +420,8 @@ async fn run_guardian_review( track_guardian_review( session.as_ref(), &review_tracking, + approval_request_source, + &reviewed_action, GuardianReviewAnalyticsResult { decision: GuardianReviewDecision::Aborted, terminal_status: GuardianReviewTerminalStatus::Aborted, @@ -446,6 +466,8 @@ async fn run_guardian_review( track_guardian_review( session.as_ref(), &review_tracking, + approval_request_source, + &reviewed_action, GuardianReviewAnalyticsResult { decision: GuardianReviewDecision::Denied, terminal_status: GuardianReviewTerminalStatus::FailedClosed, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index e56d5ce170..fed87cd75f 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -7,6 +7,7 @@ use std::time::Duration; use anyhow::anyhow; use codex_analytics::GuardianReviewAnalyticsResult; use codex_analytics::GuardianReviewSessionKind; +use codex_protocol::ThreadId; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; @@ -20,6 +21,7 @@ use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use serde_json::Value; @@ -49,7 +51,7 @@ use super::GUARDIAN_REVIEWER_NAME; use super::GuardianApprovalRequest; use super::prompt::GuardianPromptMode; use super::prompt::GuardianTranscriptCursor; -use super::prompt::build_guardian_prompt_items; +use super::prompt::build_guardian_prompt_items_with_parent_turn; use super::prompt::guardian_policy_prompt; use super::prompt::guardian_policy_prompt_with_config; @@ -183,6 +185,20 @@ impl GuardianReviewSessionReuseKey { } } +pub(crate) fn prompt_cache_key_override_for_review_session( + session_source: &SessionSource, + parent_thread_id: Option, +) -> Option { + let SessionSource::SubAgent(SubAgentSource::Other(name)) = session_source else { + return None; + }; + if name != GUARDIAN_REVIEWER_NAME { + return None; + } + let parent_thread_id = parent_thread_id?; + Some(format!("guardian:{parent_thread_id}")) +} + impl GuardianReviewSession { async fn shutdown(&self) { self.cancel_token.cancel(); @@ -670,8 +686,9 @@ async fn run_review_on_session( ) .await; - build_guardian_prompt_items( + build_guardian_prompt_items_with_parent_turn( params.parent_session.as_ref(), + Some(params.parent_turn.as_ref()), params.retry_reason.clone(), params.request.clone(), prompt_mode, @@ -774,12 +791,11 @@ async fn run_review_on_session( } async fn append_guardian_followup_reminder(review_session: &GuardianReviewSession) { - let turn_context = review_session.codex.session.new_default_turn().await; let reminder: ResponseItem = ContextualUserFragment::into(GuardianFollowupReviewReminder); review_session .codex .session - .record_conversation_items(turn_context.as_ref(), std::slice::from_ref(&reminder)) + .inject_no_new_turn(vec![reminder], /*current_turn_context*/ None) .await; } @@ -905,6 +921,7 @@ pub(crate) fn build_guardian_review_session_config( .map(guardian_policy_prompt_with_config) .unwrap_or_else(guardian_policy_prompt), ); + guardian_config.notify = None; guardian_config.developer_instructions = None; guardian_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); guardian_config @@ -1159,6 +1176,46 @@ mod tests { ); } + #[tokio::test] + async fn guardian_prompt_cache_key_is_scoped_to_parent_thread() { + let session_source = + SessionSource::SubAgent(SubAgentSource::Other(GUARDIAN_REVIEWER_NAME.to_string())); + let parent_thread_id = ThreadId::new(); + let key = + prompt_cache_key_override_for_review_session(&session_source, Some(parent_thread_id)) + .expect("guardian prompt cache key"); + + assert_eq!(key, format!("guardian:{parent_thread_id}")); + assert!( + key.len() <= 64, + "guardian prompt cache key should fit the Responses API limit" + ); + assert_eq!( + key, + prompt_cache_key_override_for_review_session(&session_source, Some(parent_thread_id)) + .expect("same guardian prompt cache key") + ); + assert_ne!( + key, + prompt_cache_key_override_for_review_session(&session_source, Some(ThreadId::new())) + .expect("different parent guardian prompt cache key") + ); + assert_eq!( + None, + prompt_cache_key_override_for_review_session( + &SessionSource::Cli, + Some(parent_thread_id) + ) + ); + assert_eq!( + None, + prompt_cache_key_override_for_review_session( + &session_source, + /*parent_thread_id*/ None + ) + ); + } + #[tokio::test] async fn guardian_review_session_compact_scope_change_invalidates_cached_session() { let parent_config = crate::config::test_config().await; diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 0544b27770..3d44fa41de 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -33,6 +33,11 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -191,7 +196,8 @@ async fn guardian_test_session_and_turn_with_base_url( async fn seed_guardian_parent_history(session: &Arc, turn: &Arc) { session - .record_into_history( + .record_conversation_items( + turn.as_ref(), &[ ResponseItem::Message { id: None, @@ -225,7 +231,6 @@ async fn seed_guardian_parent_history(session: &Arc, turn: &Arc an Ok(()) } +#[tokio::test(flavor = "current_thread")] +async fn build_guardian_prompt_includes_parent_turn_denied_reads() -> anyhow::Result<()> { + let (mut session, mut turn) = crate::session::tests::make_session_and_context().await; + session.conversation_id = fixed_guardian_parent_session_id(); + let denied_root = test_path_buf("/repo/private").abs(); + let denied_glob = test_path_buf("/repo/private/**").display().to_string(); + turn.permission_profile = PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: denied_root.clone(), + }, + access: FileSystemAccessMode::Deny, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: denied_glob.clone(), + }, + access: FileSystemAccessMode::Deny, + }, + ]), + NetworkSandboxPolicy::Restricted, + ); + let session = Arc::new(session); + let turn = Arc::new(turn); + seed_guardian_parent_history(&session, &turn).await; + + let prompt = build_guardian_prompt_items_with_parent_turn( + session.as_ref(), + Some(turn.as_ref()), + Some("Sandbox denied reading /repo/private/secret.txt.".to_string()), + GuardianApprovalRequest::Shell { + id: "shell-1".to_string(), + command: vec!["cat".to_string(), "/repo/private/secret.txt".to_string()], + cwd: test_path_buf("/repo").abs(), + sandbox_permissions: crate::sandboxing::SandboxPermissions::RequireEscalated, + additional_permissions: None, + justification: Some("Need to inspect the secret file.".to_string()), + }, + GuardianPromptMode::Full, + ) + .await?; + + let text = guardian_prompt_text(&prompt.items); + assert!(text.contains("PARENT TURN PERMISSION CONTEXT START")); + assert!(text.contains("do not approve escalation whose purpose is to read them")); + assert!(text.contains(denied_root.to_string_lossy().as_ref())); + assert!(text.contains(&format!("glob `{denied_glob}`"))); + + Ok(()) +} + #[tokio::test(flavor = "current_thread")] async fn build_guardian_prompt_delta_mode_preserves_original_numbering() -> anyhow::Result<()> { let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await; seed_guardian_parent_history(&session, &turn).await; session - .record_into_history( + .record_conversation_items( + turn.as_ref(), &[ ResponseItem::Message { id: None, @@ -381,7 +445,6 @@ async fn build_guardian_prompt_delta_mode_preserves_original_numbering() -> anyh phase: None, }, ], - turn.as_ref(), ) .await; @@ -516,7 +579,8 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() - ) .await; session - .record_into_history( + .record_conversation_items( + turn.as_ref(), &[ ResponseItem::Message { id: None, @@ -535,7 +599,6 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() - phase: None, }, ], - turn.as_ref(), ) .await; @@ -1469,7 +1532,8 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: ) .await; session - .record_into_history( + .record_conversation_items( + turn.as_ref(), &[ ResponseItem::Message { id: None, @@ -1488,7 +1552,6 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: phase: None, }, ], - turn.as_ref(), ) .await; let second_request = GuardianApprovalRequest::Shell { @@ -1513,7 +1576,8 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: ) .await; session - .record_into_history( + .record_conversation_items( + turn.as_ref(), &[ ResponseItem::Message { id: None, @@ -1532,7 +1596,6 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: phase: None, }, ], - turn.as_ref(), ) .await; let third_request = GuardianApprovalRequest::Shell { @@ -2005,7 +2068,8 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a ReviewDecision::Approved ); session - .record_into_history( + .record_conversation_items( + turn.as_ref(), &[ ResponseItem::Message { id: None, @@ -2024,7 +2088,6 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a phase: None, }, ], - turn.as_ref(), ) .await; @@ -2072,7 +2135,8 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a "second guardian request was not observed" ); session - .record_into_history( + .record_conversation_items( + turn.as_ref(), &[ ResponseItem::Message { id: None, @@ -2091,7 +2155,6 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a phase: None, }, ], - turn.as_ref(), ) .await; @@ -2106,7 +2169,13 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a assert_eq!(third_decision, ReviewDecision::Approved); let requests = server.requests().await; assert_eq!(requests.len(), 3); + let second_request_body = serde_json::from_slice::(&requests[1])?; let third_request_body = serde_json::from_slice::(&requests[2])?; + assert_eq!( + second_request_body["prompt_cache_key"], + third_request_body["prompt_cache_key"], + "forked guardian review should reuse the trunk guardian prompt cache key" + ); let third_request_body_text = third_request_body.to_string(); assert!( third_request_body_text.contains("first guardian rationale"), @@ -2215,6 +2284,25 @@ async fn guardian_review_session_config_clears_parent_developer_instructions() { ); } +#[tokio::test] +async fn guardian_review_session_config_clears_legacy_notify() { + let mut parent_config = test_config().await; + parent_config.notify = Some(vec![ + "/path/to/notify".to_string(), + "turn-ended".to_string(), + ]); + + let guardian_config = build_guardian_review_session_config_for_test( + &parent_config, + /*live_network_config*/ None, + "active-model", + /*reasoning_effort*/ None, + ) + .expect("guardian config"); + + assert_eq!(guardian_config.notify, None); +} + #[tokio::test] async fn guardian_review_session_config_uses_live_network_proxy_state() { let mut parent_config = test_config().await; diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 6452e9d5ad..80a1997477 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -500,7 +500,7 @@ pub(crate) async fn inspect_pending_input( pending_input_item: &TurnInput, ) -> HookRuntimeOutcome { match pending_input_item { - TurnInput::UserInput(content) => { + TurnInput::UserInput { content, .. } => { let request = UserPromptSubmitRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), @@ -522,7 +522,7 @@ pub(crate) async fn inspect_pending_input( ) .await } - TurnInput::ResponseInputItem(_) => HookRuntimeOutcome { + TurnInput::ResponseItem(_) => HookRuntimeOutcome { should_stop: false, additional_contexts: Vec::new(), }, @@ -536,13 +536,16 @@ pub(crate) async fn record_pending_input( additional_contexts: Vec, ) { match pending_input { - TurnInput::UserInput(content) => { - sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), content.as_slice()) - .await; + TurnInput::UserInput { content, client_id } => { + sess.record_user_prompt_and_emit_turn_item( + turn_context.as_ref(), + content.as_slice(), + client_id, + ) + .await; } - TurnInput::ResponseInputItem(input) => { - let response_item = ResponseItem::from(input); - sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) + TurnInput::ResponseItem(item) => { + sess.record_conversation_items(turn_context, std::slice::from_ref(&item)) .await; } } diff --git a/codex-rs/core/src/mcp_tool_approval_templates.rs b/codex-rs/core/src/mcp_tool_approval_templates.rs index 66002ab5ab..1c905b4f27 100644 --- a/codex-rs/core/src/mcp_tool_approval_templates.rs +++ b/codex-rs/core/src/mcp_tool_approval_templates.rs @@ -170,7 +170,7 @@ fn render_tool_params( .iter() .filter(|(name, _)| !handled_names.contains(name.as_str())) .collect::>(); - remaining_params.sort_by(|(left_name, _), (right_name, _)| left_name.cmp(right_name)); + remaining_params.sort_by_key(|(name, _)| *name); for (name, value) in remaining_params { if handled_names.contains(name.as_str()) { diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index de5276b26e..534c2f0118 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -56,13 +56,13 @@ fn annotations( destructive: Option, open_world: Option, ) -> ToolAnnotations { - ToolAnnotations { - destructive_hint: destructive, - idempotent_hint: None, - open_world_hint: open_world, - read_only_hint: read_only, - title: None, - } + ToolAnnotations::from_raw( + /*title*/ None, + read_only, + destructive, + /*idempotent_hint*/ None, + open_world, + ) } fn approval_metadata( diff --git a/codex-rs/core/src/mcp_tool_exposure.rs b/codex-rs/core/src/mcp_tool_exposure.rs index e58be65f9c..5dc66af734 100644 --- a/codex-rs/core/src/mcp_tool_exposure.rs +++ b/codex-rs/core/src/mcp_tool_exposure.rs @@ -3,6 +3,7 @@ use std::collections::HashSet; use codex_features::Feature; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::ToolInfo as McpToolInfo; +use codex_mcp::tool_is_model_visible; use crate::config::Config; use crate::connectors; @@ -51,7 +52,9 @@ pub(crate) fn build_mcp_tool_exposure( fn filter_non_codex_apps_mcp_tools_only(mcp_tools: &[McpToolInfo]) -> Vec { mcp_tools .iter() - .filter(|tool| tool.server_name != CODEX_APPS_MCP_SERVER_NAME) + .filter(|tool| { + tool.server_name != CODEX_APPS_MCP_SERVER_NAME && tool_is_model_visible(tool) + }) .cloned() .collect() } @@ -72,6 +75,9 @@ fn filter_codex_apps_mcp_tools( if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { return false; } + if !tool_is_model_visible(tool) { + return false; + } let Some(connector_id) = tool.connector_id.as_deref() else { return false; }; diff --git a/codex-rs/core/src/mcp_tool_exposure_test.rs b/codex-rs/core/src/mcp_tool_exposure_test.rs index 367baaf4a0..4918f768aa 100644 --- a/codex-rs/core/src/mcp_tool_exposure_test.rs +++ b/codex-rs/core/src/mcp_tool_exposure_test.rs @@ -7,6 +7,7 @@ use codex_mcp::ToolInfo; use codex_tools::ToolName; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; +use rmcp::model::Meta; use rmcp::model::Tool; use super::*; @@ -46,17 +47,11 @@ fn make_mcp_tool( callable_name: callable_name.to_string(), callable_namespace: callable_namespace.to_string(), namespace_description: None, - tool: Tool { - name: tool_name.to_string().into(), - title: None, - description: Some(format!("Test tool: {tool_name}").into()), - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + tool: Tool::new( + tool_name.to_string(), + format!("Test tool: {tool_name}"), + Arc::new(JsonObject::default()), + ), connector_id: connector_id.map(str::to_string), connector_name: connector_name.map(str::to_string), plugin_display_names: Vec::new(), @@ -86,6 +81,16 @@ fn tool_names(tools: &[ToolInfo]) -> HashSet { .collect() } +fn with_visibility(mut tool: ToolInfo, visibility: &[&str]) -> ToolInfo { + tool.tool.meta = Some(Meta( + serde_json::json!({ "ui": { "visibility": visibility } }) + .as_object() + .expect("metadata object") + .clone(), + )); + tool +} + #[tokio::test] async fn directly_exposes_small_effective_tool_sets() { let config = test_config().await; @@ -99,6 +104,84 @@ async fn directly_exposes_small_effective_tool_sets() { assert!(exposure.deferred_tools.is_none()); } +#[tokio::test] +async fn excludes_tools_hidden_from_model_exposure() { + let config = test_config().await; + let visible_tool = make_mcp_tool( + "rmcp", + "visible_tool", + "mcp__rmcp", + "visible_tool", + /*connector_id*/ None, + /*connector_name*/ None, + ); + let hidden_tool = with_visibility( + make_mcp_tool( + "rmcp", + "hidden_tool", + "mcp__rmcp", + "hidden_tool", + /*connector_id*/ None, + /*connector_name*/ None, + ), + &["app"], + ); + let empty_visibility_tool = with_visibility( + make_mcp_tool( + "rmcp", + "empty_visibility_tool", + "mcp__rmcp", + "empty_visibility_tool", + /*connector_id*/ None, + /*connector_name*/ None, + ), + &[], + ); + let visible_app_tool = with_visibility( + make_mcp_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_read", + "mcp__codex_apps__calendar", + "read", + Some("calendar"), + Some("Calendar"), + ), + &["app", "model"], + ); + let hidden_app_tool = with_visibility( + make_mcp_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_open", + "mcp__codex_apps__calendar", + "open", + Some("calendar"), + Some("Calendar"), + ), + &["app"], + ); + let mcp_tools = vec![ + visible_tool.clone(), + hidden_tool, + empty_visibility_tool, + visible_app_tool.clone(), + hidden_app_tool, + ]; + let connectors = vec![make_connector("calendar", "Calendar")]; + + let exposure = build_mcp_tool_exposure( + &mcp_tools, + Some(connectors.as_slice()), + &config, + /*search_tool_enabled*/ false, + ); + + assert_eq!( + tool_names(&exposure.direct_tools), + tool_names(&[visible_tool, visible_app_tool]) + ); + assert!(exposure.deferred_tools.is_none()); +} + #[tokio::test] async fn searches_large_effective_tool_sets() { let config = test_config().await; diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs index b84d6dd3eb..ba9a037d7a 100644 --- a/codex-rs/core/src/personality_migration_tests.rs +++ b/codex-rs/core/src/personality_migration_tests.rs @@ -68,6 +68,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R let user_event = RolloutLine { timestamp: TEST_TIMESTAMP.to_string(), item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "hello".to_string(), images: None, local_images: Vec::new(), diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index cf46ecba9a..135ea61194 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -27,6 +27,7 @@ use crate::tasks::UserShellCommandTask; use crate::tasks::execute_user_shell_command; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; @@ -84,12 +85,18 @@ pub async fn realtime_conversation_list_voices(sess: &Session, sub_id: String) { .await; } -pub async fn user_input_or_turn(sess: &Arc, sub_id: String, op: Op) { +pub async fn user_input_or_turn( + sess: &Arc, + sub_id: String, + op: Op, + client_user_message_id: Option, +) { user_input_or_turn_inner( sess, sub_id, op, /*mirror_user_text_to_realtime*/ Some(()), + client_user_message_id, ) .await; } @@ -189,6 +196,7 @@ pub(super) async fn user_input_or_turn_inner( sub_id: String, op: Op, mirror_user_text_to_realtime: Option<()>, + client_user_message_id: Option, ) { let Op::UserInput { items, @@ -228,6 +236,7 @@ pub(super) async fn user_input_or_turn_inner( items.clone(), additional_context.clone(), /*expected_turn_id*/ None, + client_user_message_id.clone(), responsesapi_client_metadata.clone(), ) .await @@ -255,10 +264,14 @@ pub(super) async fn user_input_or_turn_inner( }; let mut task_input = additional_context_input .into_iter() - .map(TurnInput::ResponseInputItem) + .map(ResponseItem::from) + .map(TurnInput::ResponseItem) .collect::>(); if !items.is_empty() { - task_input.push(TurnInput::UserInput(items)); + task_input.push(TurnInput::UserInput { + content: items, + client_id: client_user_message_id, + }); } sess.spawn_task( Arc::clone(¤t_context), @@ -771,7 +784,8 @@ pub(super) async fn submission_loop( false } Op::UserInput { .. } => { - user_input_or_turn(&sess, sub.id.clone(), sub.op).await; + user_input_or_turn(&sess, sub.id.clone(), sub.op, sub.client_user_message_id) + .await; false } Op::ThreadSettings { thread_settings } => { @@ -899,17 +913,14 @@ Do not assume this also authorizes similar operations with different payloads. Approved action: {approved_action_json}"#, ); - let items = vec![ResponseInputItem::Message { + let items = vec![ResponseItem::from(ResponseInputItem::Message { role: "developer".to_string(), content: vec![ContentItem::InputText { text }], phase: None, - }]; + })]; - if let Err(items) = sess.inject_response_items(items).await { - sess.input_queue - .queue_response_items_for_next_turn(items) - .await; - } + sess.inject_no_new_turn(items, /*current_turn_context*/ None) + .await; } pub(super) fn submission_dispatch_span(sub: &Submission) -> tracing::Span { diff --git a/codex-rs/core/src/session/inject.rs b/codex-rs/core/src/session/inject.rs new file mode 100644 index 0000000000..4a7189bbd6 --- /dev/null +++ b/codex-rs/core/src/session/inject.rs @@ -0,0 +1,50 @@ +use super::input_queue::TurnInput; +use super::session::Session; +use super::turn_context::TurnContext; +use codex_protocol::models::ResponseItem; + +impl Session { + /// Returns the input if there is no active turn to inject into. + #[expect( + clippy::await_holding_invalid_type, + reason = "active turn checks and turn state updates must remain atomic" + )] + pub async fn inject_if_running( + &self, + input: Vec, + ) -> Result<(), Vec> { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(active_turn) => { + self.input_queue + .extend_pending_input_for_turn_state( + active_turn.turn_state.as_ref(), + input.into_iter().map(TurnInput::ResponseItem).collect(), + ) + .await; + Ok(()) + } + None => Err(input), + } + } + + /// Injects items into active work, or records them without starting a turn. + pub(crate) async fn inject_no_new_turn( + &self, + items: Vec, + current_turn_context: Option<&TurnContext>, + ) { + let Err(items) = self.inject_if_running(items).await else { + return; + }; + let default_turn_context; + let turn_context = match current_turn_context { + Some(turn_context) => turn_context, + None => { + default_turn_context = self.new_default_turn().await; + default_turn_context.as_ref() + } + }; + self.record_conversation_items(turn_context, &items).await; + } +} diff --git a/codex-rs/core/src/session/input_queue.rs b/codex-rs/core/src/session/input_queue.rs index 620c410eb8..b62383e091 100644 --- a/codex-rs/core/src/session/input_queue.rs +++ b/codex-rs/core/src/session/input_queue.rs @@ -1,7 +1,7 @@ use crate::state::ActiveTurn; use crate::state::MailboxDeliveryPhase; use crate::state::TurnState; -use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::user_input::UserInput; use std::collections::VecDeque; @@ -11,8 +11,11 @@ use tokio::sync::watch; #[derive(Clone, Debug, PartialEq)] pub(crate) enum TurnInput { - UserInput(Vec), - ResponseInputItem(ResponseInputItem), + UserInput { + content: Vec, + client_id: Option, + }, + ResponseItem(ResponseItem), } /// Turn-local pending input storage owned by the input queue flow. @@ -25,8 +28,6 @@ pub(crate) struct TurnInputQueue { pub(crate) struct InputQueue { mailbox_tx: watch::Sender<()>, mailbox_pending_mails: Mutex>, - - idle_pending_input: Mutex>, } impl InputQueue { @@ -35,7 +36,6 @@ impl InputQueue { Self { mailbox_tx, mailbox_pending_mails: Mutex::new(VecDeque::new()), - idle_pending_input: Mutex::new(Vec::new()), } } @@ -70,31 +70,15 @@ impl InputQueue { .any(|mail| mail.trigger_turn) } - pub(crate) async fn drain_mailbox_input_items(&self) -> Vec { + pub(crate) async fn drain_mailbox_input_items(&self) -> Vec { self.mailbox_pending_mails .lock() .await .drain(..) - .map(|mail| mail.to_response_input_item()) + .map(|mail| ResponseItem::from(mail.to_response_input_item())) .collect() } - pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec) { - if items.is_empty() { - return; - } - - self.idle_pending_input.lock().await.extend(items); - } - - pub(crate) async fn take_queued_response_items_for_next_turn(&self) -> Vec { - std::mem::take(&mut *self.idle_pending_input.lock().await) - } - - pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool { - !self.idle_pending_input.lock().await.is_empty() - } - pub(crate) async fn turn_state_for_sub_id( &self, active_turn: &Mutex>, @@ -181,32 +165,6 @@ impl InputQueue { turn_state.lock().await.pending_input.items.split_off(0) } - #[expect( - clippy::await_holding_invalid_type, - reason = "active turn checks and turn state updates must remain atomic" - )] - pub(crate) async fn inject_response_items( - &self, - active_turn: &Mutex>, - input: Vec, - ) -> Result<(), Vec> { - let mut active = active_turn.lock().await; - match active.as_mut() { - Some(active_turn) => { - self.extend_pending_input_for_turn_state( - active_turn.turn_state.as_ref(), - input - .into_iter() - .map(TurnInput::ResponseInputItem) - .collect(), - ) - .await; - Ok(()) - } - None => Err(input), - } - } - #[expect( clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" @@ -235,7 +193,7 @@ impl InputQueue { .drain_mailbox_input_items() .await .into_iter() - .map(TurnInput::ResponseInputItem); + .map(TurnInput::ResponseItem); if pending_input.is_empty() { mailbox_items.collect() } else { @@ -345,8 +303,8 @@ mod tests { assert_eq!( input_queue.drain_mailbox_input_items().await, vec![ - mail_one.to_response_input_item(), - mail_two.to_response_input_item() + ResponseItem::from(mail_one.to_response_input_item()), + ResponseItem::from(mail_two.to_response_input_item()) ] ); assert!(!input_queue.has_pending_mailbox_items().await); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 532b2239cd..22a95c1a93 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -72,6 +72,7 @@ use codex_network_proxy::normalize_host; use codex_otel::current_span_trace_id; use codex_otel::current_span_w3c_trace_context; use codex_otel::set_parent_from_w3c_trace_context; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::approvals::ExecPolicyAmendment; @@ -195,6 +196,7 @@ use codex_protocol::exec_output::StreamOutput; mod config_lock; mod handlers; +mod inject; mod input_queue; mod mcp; mod multi_agents; @@ -323,7 +325,6 @@ use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -548,37 +549,9 @@ impl Codex { .or_else(|| conversation_history.get_base_instructions().map(|s| s.text)) .unwrap_or_else(|| model_info.get_model_instructions(config.personality)); - // Respect thread-start tools. When missing (resumed/forked threads), read from the db - // first, then fall back to rollout-file tools. - let persisted_tools = if dynamic_tools.is_empty() { - let thread_id = match &conversation_history { - InitialHistory::Resumed(resumed) => Some(resumed.conversation_id), - InitialHistory::Forked(_) => conversation_history.forked_from_id(), - InitialHistory::New | InitialHistory::Cleared => None, - }; - match thread_id { - Some(thread_id) => { - let state_db_ctx = if config.ephemeral { - None - } else if let Some(local_store) = - thread_store.as_any().downcast_ref::() - { - local_store.state_db().await - } else { - None - }; - state_db::get_dynamic_tools(state_db_ctx.as_deref(), thread_id, "codex_spawn") - .await - } - None => None, - } - } else { - None - }; + // Dynamic tools are defined at thread start and persisted in rollout session metadata. let dynamic_tools = if dynamic_tools.is_empty() { - persisted_tools - .or_else(|| conversation_history.get_dynamic_tools()) - .unwrap_or_default() + conversation_history.get_dynamic_tools().unwrap_or_default() } else { dynamic_tools }; @@ -694,6 +667,25 @@ impl Codex { let sub = Submission { id: id.clone(), op, + client_user_message_id: None, + trace, + }; + self.submit_with_id(sub).await?; + Ok(id) + } + + pub async fn submit_user_input_with_client_user_message_id( + &self, + op: Op, + trace: Option, + client_user_message_id: Option, + ) -> CodexResult { + debug_assert!(matches!(op, Op::UserInput { .. })); + let id = Uuid::now_v7().to_string(); + let sub = Submission { + id: id.clone(), + op, + client_user_message_id, trace, }; self.submit_with_id(sub).await?; @@ -749,6 +741,7 @@ impl Codex { input: Vec, additional_context: BTreeMap, expected_turn_id: Option<&str>, + client_user_message_id: Option, responsesapi_client_metadata: Option>, ) -> Result { self.session @@ -756,6 +749,7 @@ impl Codex { input, additional_context, expected_turn_id, + client_user_message_id, responsesapi_client_metadata, ) .await @@ -1101,6 +1095,7 @@ impl Session { thread_settings: Default::default(), }, /*mirror_user_text_to_realtime*/ None, + /*client_user_message_id*/ None, ) .await; } @@ -1870,27 +1865,11 @@ impl Session { warn!("execpolicy amendment for {sub_id} had no command prefix"); return; }; - let fragment = ApprovedCommandPrefixSaved::new(prefixes); - let text = fragment.render(); - let message: ResponseItem = ContextualUserFragment::into(fragment); - - if let Some(turn_context) = self.turn_context_for_sub_id(sub_id).await { - self.record_conversation_items(&turn_context, std::slice::from_ref(&message)) - .await; - return; - } - - if self - .inject_response_items(vec![ResponseInputItem::Message { - role: "developer".to_string(), - content: vec![ContentItem::InputText { text }], - phase: None, - }]) - .await - .is_err() - { - warn!("no active turn found to record execpolicy amendment message for {sub_id}"); - } + let message: ResponseItem = + ContextualUserFragment::into(ApprovedCommandPrefixSaved::new(prefixes)); + let turn_context = self.turn_context_for_sub_id(sub_id).await; + self.inject_no_new_turn(vec![message], turn_context.as_deref()) + .await; } pub(crate) async fn persist_network_policy_amendment( @@ -1967,27 +1946,10 @@ impl Session { sub_id: &str, amendment: &NetworkPolicyAmendment, ) { - let fragment = NetworkRuleSaved::new(amendment); - let text = fragment.render(); - let message: ResponseItem = ContextualUserFragment::into(fragment); - - if let Some(turn_context) = self.turn_context_for_sub_id(sub_id).await { - self.record_conversation_items(&turn_context, std::slice::from_ref(&message)) - .await; - return; - } - - if self - .inject_response_items(vec![ResponseInputItem::Message { - role: "developer".to_string(), - content: vec![ContentItem::InputText { text }], - phase: None, - }]) - .await - .is_err() - { - warn!("no active turn found to record network policy amendment message for {sub_id}"); - } + let message: ResponseItem = ContextualUserFragment::into(NetworkRuleSaved::new(amendment)); + let turn_context = self.turn_context_for_sub_id(sub_id).await; + self.inject_no_new_turn(vec![message], turn_context.as_deref()) + .await; } /// Emit an exec approval request event and await the user's decision. @@ -2538,28 +2500,21 @@ impl Session { } } - /// Records input items: always append to conversation history and - /// persist these response items to rollout. + /// Records conversation items: append to history, persist to rollout, and + /// notify clients observing raw response items. pub(crate) async fn record_conversation_items( &self, turn_context: &TurnContext, items: &[ResponseItem], ) { - self.record_into_history(items, turn_context).await; + { + let mut state = self.state.lock().await; + state.record_items(items.iter(), turn_context.truncation_policy); + } self.persist_rollout_response_items(items).await; self.send_raw_response_items(turn_context, items).await; } - /// Append ResponseItems to the in-memory conversation history only. - pub(crate) async fn record_into_history( - &self, - items: &[ResponseItem], - turn_context: &TurnContext, - ) { - let mut state = self.state.lock().await; - state.record_items(items.iter(), turn_context.truncation_policy); - } - async fn maybe_warn_on_server_model_mismatch( self: &Arc, turn_context: &Arc, @@ -3124,6 +3079,7 @@ impl Session { &self, turn_context: &TurnContext, input: &[UserInput], + client_id: Option, ) { // Persist the user message to history, but emit the turn item from `UserInput` so // UI-only `text_elements` are preserved. `ResponseItem::Message` does not carry @@ -3131,7 +3087,9 @@ impl Session { let response_item = ResponseItem::from(ResponseInputItem::from(input.to_vec())); self.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) .await; - let turn_item = TurnItem::UserMessage(UserMessageItem::new(input)); + let mut user_message_item = UserMessageItem::new(input); + user_message_item.client_id = client_id; + let turn_item = TurnItem::UserMessage(user_message_item); self.emit_turn_item_started(turn_context, &turn_item).await; self.emit_turn_item_completed(turn_context, turn_item).await; self.ensure_rollout_materialized().await; @@ -3167,6 +3125,7 @@ impl Session { input: Vec, additional_context: BTreeMap, expected_turn_id: Option<&str>, + client_user_message_id: Option, responsesapi_client_metadata: Option>, ) -> Result { let mut active = self.active_turn.lock().await; @@ -3220,9 +3179,13 @@ impl Session { let mut pending_input = additional_context_input .into_iter() - .map(TurnInput::ResponseInputItem) + .map(ResponseItem::from) + .map(TurnInput::ResponseItem) .collect::>(); - pending_input.push(TurnInput::UserInput(input)); + pending_input.push(TurnInput::UserInput { + content: input, + client_id: client_user_message_id, + }); self.input_queue .extend_pending_input_and_accept_mailbox_delivery_for_turn_state( active_turn.turn_state.as_ref(), @@ -3232,16 +3195,6 @@ impl Session { Ok(active_turn_id.clone()) } - /// Returns the input if there was no task running to inject into. - pub async fn inject_response_items( - &self, - input: Vec, - ) -> Result<(), Vec> { - self.input_queue - .inject_response_items(&self.active_turn, input) - .await - } - pub(crate) async fn record_memory_citation_for_turn(&self, sub_id: &str) { let turn_state = self .input_queue @@ -3316,6 +3269,7 @@ impl Session { pub(crate) fn emit_subagent_session_started( analytics_events_client: &AnalyticsEventsClient, client_metadata: AppServerClientMetadata, + session_id: SessionId, thread_id: ThreadId, parent_thread_id: Option, thread_config: ThreadConfigSnapshot, @@ -3334,6 +3288,7 @@ pub(crate) fn emit_subagent_session_started( .unwrap_or_default() .as_secs(); analytics_events_client.track_subagent_thread_started(SubAgentThreadStartedInput { + session_id: session_id.to_string(), thread_id: thread_id.to_string(), parent_thread_id: parent_thread_id.map(|thread_id| thread_id.to_string()), product_client_id: client_name.clone(), diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 762ebc1458..75b7539090 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -137,11 +137,14 @@ pub(super) async fn spawn_review_thread( }; // Seed the child task with the review prompt as the initial user message. - let input = vec![TurnInput::UserInput(vec![UserInput::Text { - text: review_prompt, - // Review prompt is synthesized; no UI element ranges to preserve. - text_elements: Vec::new(), - }])]; + let input = vec![TurnInput::UserInput { + content: vec![UserInput::Text { + text: review_prompt, + // Review prompt is synthesized; no UI element ranges to preserve. + text_elements: Vec::new(), + }], + client_id: None, + }]; let tc = Arc::new(review_turn_context); tc.turn_metadata_state.spawn_git_enrichment_task(); // TODO(ccunningham): Review turns currently rely on `spawn_task` for TurnComplete but do not diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 11b8651ae6..05be3e8967 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -61,6 +61,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -98,6 +99,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -130,6 +132,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "seed".to_string(), images: None, local_images: Vec::new(), @@ -198,6 +201,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "turn 1 user".to_string(), images: None, local_images: Vec::new(), @@ -228,6 +232,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "turn 2 user".to_string(), images: None, local_images: Vec::new(), @@ -300,6 +305,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "turn 1 user".to_string(), images: None, local_images: Vec::new(), @@ -330,6 +336,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "turn 2 user".to_string(), images: None, local_images: Vec::new(), @@ -394,6 +401,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "turn 1 user".to_string(), images: None, local_images: Vec::new(), @@ -424,6 +432,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "turn 2 user".to_string(), images: None, local_images: Vec::new(), @@ -517,6 +526,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "turn 1 user".to_string(), images: None, local_images: Vec::new(), @@ -608,6 +618,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "only user".to_string(), images: None, local_images: Vec::new(), @@ -662,6 +673,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "seed".to_string(), images: None, local_images: Vec::new(), @@ -737,6 +749,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "seed".to_string(), images: None, local_images: Vec::new(), @@ -765,6 +778,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "rolled back".to_string(), images: None, local_images: Vec::new(), @@ -898,6 +912,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "after legacy compact".to_string(), images: None, local_images: Vec::new(), @@ -933,6 +948,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -963,6 +979,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "seed".to_string(), images: None, local_images: Vec::new(), @@ -1009,6 +1026,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1036,6 +1054,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1068,6 +1087,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "seed".to_string(), images: None, local_images: Vec::new(), @@ -1096,6 +1116,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "aborted".to_string(), images: None, local_images: Vec::new(), @@ -1151,6 +1172,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo turn_id: Some(current_turn_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1178,6 +1200,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "seed".to_string(), images: None, local_images: Vec::new(), @@ -1206,6 +1229,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "current".to_string(), images: None, local_images: Vec::new(), @@ -1265,6 +1289,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1297,6 +1322,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "seed".to_string(), images: None, local_images: Vec::new(), @@ -1325,6 +1351,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "incomplete".to_string(), images: None, local_images: Vec::new(), @@ -1377,6 +1404,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "incomplete".to_string(), images: None, local_images: Vec::new(), @@ -1419,6 +1447,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1452,6 +1481,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "seed".to_string(), images: None, local_images: Vec::new(), @@ -1480,6 +1510,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "compacted".to_string(), images: None, local_images: Vec::new(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index bba168c645..0f7243b8f7 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -972,6 +972,8 @@ impl Session { for contributor in extensions.thread_lifecycle_contributors() { contributor.on_thread_start(codex_extension_api::ThreadStartInput { config: config.as_ref(), + session_source: &session_configuration.session_source, + persistent_thread_state_available: state_db_ctx.is_some(), session_store: &session_extension_data, thread_store: &thread_extension_data, }).await; @@ -1041,6 +1043,12 @@ impl Session { config.features.enabled(Feature::RuntimeMetrics), Self::build_model_client_beta_features_header(config.as_ref()), attestation_provider, + ) + .with_prompt_cache_key_override( + crate::guardian::prompt_cache_key_override_for_review_session( + &session_configuration.session_source, + session_configuration.forked_from_thread_id, + ), ), code_mode_service: crate::tools::code_mode::CodeModeService::new(), environment_manager, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index e7613b52bb..c14c3d5851 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -103,9 +103,9 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::CreditsSnapshot; @@ -398,6 +398,12 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + let marker_evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("expected turn aborted marker event") + .expect("channel open"); + assert!(matches!(marker_evt.msg, EventMsg::RawResponseItem(_))); + let second = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) .await .expect("expected turn aborted event") @@ -1772,7 +1778,7 @@ async fn recompute_token_usage_uses_session_base_instructions() { let item = user_message("hello"); session - .record_into_history(std::slice::from_ref(&item), &turn_context) + .record_conversation_items(&turn_context, std::slice::from_ref(&item)) .await; let history = session.clone_history().await; @@ -2037,6 +2043,85 @@ async fn turn_start_lifecycle_exposes_turn_metadata_and_token_baseline() { assert_eq!(vec![expected], actual); } +#[tokio::test] +async fn turn_error_lifecycle_exposes_error_and_stores() { + struct SessionTurnErrorMarker; + struct ThreadTurnErrorMarker; + + #[derive(Debug, PartialEq, Eq)] + struct RecordedTurnError { + session_level_id: String, + thread_level_id: String, + turn_level_id: String, + turn_id: String, + error: CodexErrorInfo, + saw_session_store: bool, + saw_thread_store: bool, + } + + struct TurnErrorRecorder { + records: Arc>>, + } + + #[async_trait::async_trait] + impl codex_extension_api::TurnLifecycleContributor for TurnErrorRecorder { + async fn on_turn_error(&self, input: codex_extension_api::TurnErrorInput<'_>) { + self.records + .lock() + .expect("turn error records lock") + .push(RecordedTurnError { + session_level_id: input.session_store.level_id().to_string(), + thread_level_id: input.thread_store.level_id().to_string(), + turn_level_id: input.turn_store.level_id().to_string(), + turn_id: input.turn_id.to_string(), + error: input.error, + saw_session_store: input + .session_store + .get::() + .is_some(), + saw_thread_store: input.thread_store.get::().is_some(), + }); + } + } + + let (mut session, turn_context) = make_session_and_context().await; + let records = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut builder = codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.turn_lifecycle_contributor(Arc::new(TurnErrorRecorder { + records: Arc::clone(&records), + })); + session.services.extensions = Arc::new(builder.build()); + session + .services + .session_extension_data + .insert(SessionTurnErrorMarker); + session + .services + .thread_extension_data + .insert(ThreadTurnErrorMarker); + + let expected = RecordedTurnError { + session_level_id: session.session_id().to_string(), + thread_level_id: session.conversation_id.to_string(), + turn_level_id: turn_context.sub_id.clone(), + turn_id: turn_context.sub_id.clone(), + error: CodexErrorInfo::UsageLimitExceeded, + saw_session_store: true, + saw_thread_store: true, + }; + + session + .emit_turn_error_lifecycle(&turn_context, CodexErrorInfo::UsageLimitExceeded) + .await; + + let actual = records + .lock() + .expect("turn error records lock") + .drain(..) + .collect::>(); + assert_eq!(vec![expected], actual); +} + #[tokio::test] async fn config_change_contributor_observes_effective_config_changes() { struct SessionConfigMarker; @@ -2375,6 +2460,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { turn_id: Some(turn_context.sub_id.clone()), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), + workspace_roots: None, current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -2405,6 +2491,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "forked seed".to_string(), images: None, local_images: Vec::new(), @@ -2547,7 +2634,7 @@ async fn thread_rollback_fails_without_persisted_thread_history() { let (sess, tc, rx) = make_session_and_context_with_rx().await; let initial_context = sess.build_initial_context(tc.as_ref()).await; - sess.record_into_history(&initial_context, tc.as_ref()) + sess.record_conversation_items(tc.as_ref(), &initial_context) .await; handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; @@ -2601,6 +2688,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "turn 1 user".to_string(), images: None, local_images: Vec::new(), @@ -2629,6 +2717,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context )), RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "turn 2 user".to_string(), images: None, local_images: Vec::new(), @@ -2713,6 +2802,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio }, )), RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "turn 1 user".to_string(), images: None, local_images: Vec::new(), @@ -2759,6 +2849,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio }, )), RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "turn 2 user".to_string(), images: None, local_images: Vec::new(), @@ -2815,6 +2906,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { }, )), RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "turn 1 user".to_string(), images: None, local_images: Vec::new(), @@ -2841,6 +2933,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { }, )), RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "turn 2 user".to_string(), images: None, local_images: Vec::new(), @@ -2867,6 +2960,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { }, )), RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "turn 3 user".to_string(), images: None, local_images: Vec::new(), @@ -2920,7 +3014,7 @@ async fn thread_rollback_fails_when_turn_in_progress() { let (sess, tc, rx) = make_session_and_context_with_rx().await; let initial_context = sess.build_initial_context(tc.as_ref()).await; - sess.record_into_history(&initial_context, tc.as_ref()) + sess.record_conversation_items(tc.as_ref(), &initial_context) .await; *sess.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); @@ -2941,7 +3035,7 @@ async fn thread_rollback_fails_when_num_turns_is_zero() { let (sess, tc, rx) = make_session_and_context_with_rx().await; let initial_context = sess.build_initial_context(tc.as_ref()).await; - sess.record_into_history(&initial_context, tc.as_ref()) + sess.record_conversation_items(tc.as_ref(), &initial_context) .await; handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 0).await; @@ -5326,6 +5420,7 @@ async fn submit_with_id_captures_current_span_trace_context() { .submit_with_id(Submission { id: "sub-1".into(), op: Op::Interrupt, + client_user_message_id: None, trace: None, }) .await @@ -5397,6 +5492,7 @@ fn submission_dispatch_span_prefers_submission_trace_context() { submission_dispatch_span(&Submission { id: "sub-1".into(), op: Op::Interrupt, + client_user_message_id: None, trace: Some(submission_trace), }) }); @@ -5423,6 +5519,7 @@ fn submission_dispatch_span_uses_debug_for_realtime_audio() { item_id: None, }, }), + client_user_message_id: None, trace: None, }); @@ -5490,6 +5587,7 @@ async fn user_turn_updates_approvals_reviewer() { ..Default::default() }, }, + /*client_user_message_id*/ None, ) .await; @@ -5783,6 +5881,7 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() { let dispatch_span = submission_dispatch_span(&Submission { id: "sub-1".into(), op: Op::Interrupt, + client_user_message_id: None, trace: Some(submission_trace.clone()), }); let dispatch_span_id = dispatch_span.context().span().span_context().span_id(); @@ -5793,10 +5892,13 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() { async { sess.spawn_task( Arc::clone(&tc), - vec![TurnInput::UserInput(vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }])], + vec![TurnInput::UserInput { + content: vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + client_id: None, + }], TraceCaptureTask { captured_trace: Arc::clone(&captured_trace), }, @@ -6606,10 +6708,13 @@ async fn spawn_task_does_not_update_previous_turn_settings_for_non_run_turn_task let (sess, tc, _rx) = make_session_and_context_with_rx().await; sess.set_previous_turn_settings(/*previous_turn_settings*/ None) .await; - let input = vec![TurnInput::UserInput(vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }])]; + let input = vec![TurnInput::UserInput { + content: vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + client_id: None, + }]; sess.spawn_task( Arc::clone(&tc), @@ -7533,7 +7638,7 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co phase: None, }; session - .record_into_history(std::slice::from_ref(&compacted_summary), &turn_context) + .record_conversation_items(&turn_context, std::slice::from_ref(&compacted_summary)) .await; session .record_context_updates_and_set_reference_context_item(&turn_context) @@ -7692,6 +7797,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei session .persist_rollout_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( UserMessageEvent { + client_id: None, message: "seed rollout".to_string(), images: None, local_images: Vec::new(), @@ -7811,6 +7917,29 @@ async fn realtime_conversation_list_voices_emits_builtin_list() { ); } +#[derive(Clone, Copy)] +struct CompletingTask; + +impl SessionTask for CompletingTask { + fn kind(&self) -> TaskKind { + TaskKind::Regular + } + + fn span_name(&self) -> &'static str { + "session_task.completing" + } + + async fn run( + self: Arc, + _session: Arc, + _ctx: Arc, + _input: Vec, + _cancellation_token: CancellationToken, + ) -> Option { + None + } +} + #[derive(Clone, Copy)] struct NeverEndingTask { kind: TaskKind, @@ -7875,10 +8004,13 @@ impl SessionTask for GuardianDeniedApprovalTask { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn guardian_auto_review_interrupts_after_three_consecutive_denials() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let input = vec![TurnInput::UserInput(vec![UserInput::Text { - text: "trigger guardian denials".to_string(), - text_elements: Vec::new(), - }])]; + let input = vec![TurnInput::UserInput { + content: vec![UserInput::Text { + text: "trigger guardian denials".to_string(), + text_elements: Vec::new(), + }], + client_id: None, + }]; sess.spawn_task(Arc::clone(&tc), input, GuardianDeniedApprovalTask) .await; @@ -7906,10 +8038,13 @@ async fn guardian_auto_review_interrupts_after_three_consecutive_denials() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn guardian_helper_review_interrupts_after_three_consecutive_denials() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let input = vec![TurnInput::UserInput(vec![UserInput::Text { - text: "keep turn active for helper reviews".to_string(), - text_elements: Vec::new(), - }])]; + let input = vec![TurnInput::UserInput { + content: vec![UserInput::Text { + text: "keep turn active for helper reviews".to_string(), + text_elements: Vec::new(), + }], + client_id: None, + }]; sess.spawn_task( Arc::clone(&tc), input, @@ -7964,12 +8099,15 @@ async fn guardian_helper_review_interrupts_after_three_consecutive_denials() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_log::test] -async fn abort_regular_task_emits_turn_aborted_only() { +async fn abort_regular_task_emits_marker_before_turn_aborted() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let input = vec![TurnInput::UserInput(vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }])]; + let input = vec![TurnInput::UserInput { + content: vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + client_id: None, + }]; sess.spawn_task( Arc::clone(&tc), input, @@ -7982,8 +8120,13 @@ async fn abort_regular_task_emits_turn_aborted_only() { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - // Interrupts persist a model-visible `` marker into history, but there is no - // separate client-visible event for that marker (only `EventMsg::TurnAborted`). + // Interrupts surface the model-visible `` marker before the abort event. + let marker_evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for marker event") + .expect("event"); + assert!(matches!(marker_evt.msg, EventMsg::RawResponseItem(_))); + let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) .await .expect("timeout waiting for event") @@ -7997,12 +8140,15 @@ async fn abort_regular_task_emits_turn_aborted_only() { } #[tokio::test] -async fn abort_gracefully_emits_turn_aborted_only() { +async fn abort_gracefully_emits_marker_before_turn_aborted() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let input = vec![TurnInput::UserInput(vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }])]; + let input = vec![TurnInput::UserInput { + content: vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + client_id: None, + }]; sess.spawn_task( Arc::clone(&tc), input, @@ -8015,8 +8161,13 @@ async fn abort_gracefully_emits_turn_aborted_only() { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - // Even if tasks handle cancellation gracefully, interrupts still result in `TurnAborted` - // being the only client-visible signal. + // Gracefully cancelled tasks surface the model-visible marker before the abort event too. + let marker_evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for marker event") + .expect("event"); + assert!(matches!(marker_evt.msg, EventMsg::RawResponseItem(_))); + let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) .await .expect("timeout waiting for event") @@ -8032,10 +8183,13 @@ async fn abort_gracefully_emits_turn_aborted_only() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let input = vec![TurnInput::UserInput(vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }])]; + let input = vec![TurnInput::UserInput { + content: vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + client_id: None, + }]; sess.spawn_task( Arc::clone(&tc), input, @@ -8060,6 +8214,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() pending_user_input.clone(), /*additional_context*/ Default::default(), Some(&tc.sub_id), + /*client_user_message_id*/ None, /*responsesapi_client_metadata*/ None, ) .await @@ -8119,6 +8274,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() assert!(matches!( fourth.msg, EventMsg::UserMessage(UserMessageEvent { + client_id: None, message, images, text_elements, @@ -8145,6 +8301,86 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() )); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn task_finish_emits_thread_idle_lifecycle_after_active_turn_clears() { + struct ThreadIdleRecorder { + calls: Arc, + idle_tx: async_channel::Sender<()>, + expected_thread_id: ThreadId, + } + + #[async_trait::async_trait] + impl codex_extension_api::ThreadLifecycleContributor for ThreadIdleRecorder { + async fn on_thread_idle(&self, input: codex_extension_api::ThreadIdleInput<'_>) { + assert_eq!( + self.expected_thread_id.to_string(), + input.thread_store.level_id() + ); + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.idle_tx.send(()).await.expect("idle receiver open"); + } + } + + let (mut session, turn_context) = make_session_and_context().await; + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (idle_tx, idle_rx) = async_channel::bounded(1); + let mut builder = codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.thread_lifecycle_contributor(Arc::new(ThreadIdleRecorder { + calls: Arc::clone(&calls), + idle_tx, + expected_thread_id: session.conversation_id, + })); + session.services.extensions = Arc::new(builder.build()); + + let session = Arc::new(session); + session + .spawn_task(Arc::new(turn_context), Vec::new(), CompletingTask) + .await; + + timeout(StdDuration::from_secs(2), idle_rx.recv()) + .await + .expect("thread idle lifecycle") + .expect("idle receiver open"); + assert_eq!(1, calls.load(std::sync::atomic::Ordering::SeqCst)); + assert!(session.active_turn.lock().await.is_none()); +} + +#[tokio::test] +async fn thread_idle_lifecycle_waits_for_trigger_turn_mailbox_work() { + struct ThreadIdleRecorder { + calls: Arc, + } + + #[async_trait::async_trait] + impl codex_extension_api::ThreadLifecycleContributor for ThreadIdleRecorder { + async fn on_thread_idle(&self, _input: codex_extension_api::ThreadIdleInput<'_>) { + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + } + + let (mut session, _turn_context) = make_session_and_context().await; + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut builder = codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.thread_lifecycle_contributor(Arc::new(ThreadIdleRecorder { + calls: Arc::clone(&calls), + })); + session.services.extensions = Arc::new(builder.build()); + session + .input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + AgentPath::root(), + AgentPath::root(), + Vec::new(), + "pending trigger".to_string(), + /*trigger_turn*/ true, + )) + .await; + + session.emit_thread_idle_lifecycle_if_idle().await; + + assert_eq!(0, calls.load(std::sync::atomic::Ordering::SeqCst)); +} + #[tokio::test] async fn steer_input_requires_active_turn() { let (sess, _tc, _rx) = make_session_and_context_with_rx().await; @@ -8158,6 +8394,7 @@ async fn steer_input_requires_active_turn() { input, /*additional_context*/ Default::default(), /*expected_turn_id*/ None, + /*client_user_message_id*/ None, /*responsesapi_client_metadata*/ None, ) .await @@ -8169,10 +8406,13 @@ async fn steer_input_requires_active_turn() { #[tokio::test] async fn steer_input_enforces_expected_turn_id() { let (sess, tc, _rx) = make_session_and_context_with_rx().await; - let input = vec![TurnInput::UserInput(vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }])]; + let input = vec![TurnInput::UserInput { + content: vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + client_id: None, + }]; sess.spawn_task( Arc::clone(&tc), input, @@ -8192,6 +8432,7 @@ async fn steer_input_enforces_expected_turn_id() { steer_input, /*additional_context*/ Default::default(), Some("different-turn-id"), + /*client_user_message_id*/ None, /*responsesapi_client_metadata*/ None, ) .await @@ -8215,10 +8456,13 @@ async fn steer_input_rejects_non_regular_turns() { (TaskKind::Compact, NonSteerableTurnKind::Compact), ] { let (sess, _tc, _rx) = make_session_and_context_with_rx().await; - let input = vec![TurnInput::UserInput(vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }])]; + let input = vec![TurnInput::UserInput { + content: vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + client_id: None, + }]; let turn_context = sess.new_default_turn_with_sub_id("turn".to_string()).await; sess.spawn_task( turn_context, @@ -8239,6 +8483,7 @@ async fn steer_input_rejects_non_regular_turns() { steer_input, /*additional_context*/ Default::default(), /*expected_turn_id*/ None, + /*client_user_message_id*/ None, /*responsesapi_client_metadata*/ None, ) .await @@ -8253,10 +8498,13 @@ async fn steer_input_rejects_non_regular_turns() { #[tokio::test] async fn steer_input_returns_active_turn_id() { let (sess, tc, _rx) = make_session_and_context_with_rx().await; - let input = vec![TurnInput::UserInput(vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }])]; + let input = vec![TurnInput::UserInput { + content: vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + client_id: None, + }]; sess.spawn_task( Arc::clone(&tc), input, @@ -8276,6 +8524,7 @@ async fn steer_input_returns_active_turn_id() { steer_input, /*additional_context*/ Default::default(), Some(&tc.sub_id), + /*client_user_message_id*/ None, /*responsesapi_client_metadata*/ None, ) .await @@ -8285,66 +8534,11 @@ async fn steer_input_returns_active_turn_id() { assert!(sess.input_queue.has_pending_input(&sess.active_turn).await); } -#[tokio::test] -async fn queued_response_items_for_next_turn_move_into_next_active_turn() { - let (sess, tc, _rx) = make_session_and_context_with_rx().await; - let queued_item = ResponseInputItem::Message { - role: "assistant".to_string(), - content: vec![ContentItem::InputText { - text: "queued before wake".to_string(), - }], - phase: None, - }; - - sess.input_queue - .queue_response_items_for_next_turn(vec![queued_item.clone()]) - .await; - - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - - assert_eq!( - sess.input_queue.get_pending_input(&sess.active_turn).await, - vec![TurnInput::ResponseInputItem(queued_item)] - ); -} - -#[tokio::test] -async fn idle_interrupt_does_not_wake_queued_next_turn_items() { - let (sess, _tc, _rx) = make_session_and_context_with_rx().await; - let queued_item = ResponseInputItem::Message { - role: "assistant".to_string(), - content: vec![ContentItem::InputText { - text: "queued before interrupt".to_string(), - }], - phase: None, - }; - - sess.input_queue - .queue_response_items_for_next_turn(vec![queued_item]) - .await; - - sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - - assert!(sess.active_turn.lock().await.is_none()); - assert!( - sess.input_queue - .has_queued_response_items_for_next_turn() - .await - ); -} - #[tokio::test] async fn abort_empty_active_turn_preserves_pending_input() { let (sess, _tc, _rx) = make_session_and_context_with_rx().await; - let pending_item = ResponseInputItem::Message { + let pending_item = ResponseItem::Message { + id: None, role: "user".to_string(), content: vec![ContentItem::InputText { text: "late pending input".to_string(), @@ -8359,7 +8553,7 @@ async fn abort_empty_active_turn_preserves_pending_input() { sess.input_queue .extend_pending_input_for_turn_state( turn_state.as_ref(), - vec![TurnInput::ResponseInputItem(pending_item.clone())], + vec![TurnInput::ResponseItem(pending_item.clone())], ) .await; @@ -8370,7 +8564,7 @@ async fn abort_empty_active_turn_preserves_pending_input() { sess.input_queue .take_pending_input_for_turn_state(turn_state.as_ref()) .await, - vec![TurnInput::ResponseInputItem(pending_item)] + vec![TurnInput::ResponseItem(pending_item)] ); } @@ -8796,7 +8990,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh .await?; let pending_input = sess.input_queue.get_pending_input(&sess.active_turn).await; - let [TurnInput::ResponseInputItem(ResponseInputItem::Message { role, content, .. })] = + let [TurnInput::ResponseItem(ResponseItem::Message { role, content, .. })] = pending_input.as_slice() else { panic!("expected one budget-limit steering message, got {pending_input:#?}"); @@ -9023,7 +9217,7 @@ async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> { pending_input.iter().any(|item| { matches!( item, - TurnInput::ResponseInputItem(ResponseInputItem::Message { role, content, .. }) + TurnInput::ResponseItem(ResponseItem::Message { role, content, .. }) if role == "user" && content.iter().any(|content| matches!( content, @@ -9247,9 +9441,9 @@ async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() { assert_eq!( sess.input_queue.get_pending_input(&sess.active_turn).await, - vec![TurnInput::ResponseInputItem( + vec![TurnInput::ResponseItem(ResponseItem::from( communication.to_response_input_item() - )], + ))], ); } @@ -9322,6 +9516,7 @@ async fn steered_input_reopens_mailbox_delivery_for_current_turn() { }], /*additional_context*/ Default::default(), Some(&tc.sub_id), + /*client_user_message_id*/ None, /*responsesapi_client_metadata*/ None, ) .await @@ -9330,11 +9525,14 @@ async fn steered_input_reopens_mailbox_delivery_for_current_turn() { assert_eq!( sess.input_queue.get_pending_input(&sess.active_turn).await, vec![ - TurnInput::UserInput(vec![UserInput::Text { - text: "follow up".to_string(), - text_elements: Vec::new(), - }]), - TurnInput::ResponseInputItem(communication.to_response_input_item()), + TurnInput::UserInput { + content: vec![UserInput::Text { + text: "follow up".to_string(), + text_elements: Vec::new(), + }], + client_id: None + }, + TurnInput::ResponseItem(ResponseItem::from(communication.to_response_input_item())), ], ); } @@ -9372,6 +9570,7 @@ async fn stale_defer_mailbox_delivery_does_not_override_steered_input() { }], /*additional_context*/ Default::default(), Some(&tc.sub_id), + /*client_user_message_id*/ None, /*responsesapi_client_metadata*/ None, ) .await @@ -9384,11 +9583,14 @@ async fn stale_defer_mailbox_delivery_does_not_override_steered_input() { assert_eq!( sess.input_queue.get_pending_input(&sess.active_turn).await, vec![ - TurnInput::UserInput(vec![UserInput::Text { - text: "follow up".to_string(), - text_elements: Vec::new(), - }]), - TurnInput::ResponseInputItem(communication.to_response_input_item()), + TurnInput::UserInput { + content: vec![UserInput::Text { + text: "follow up".to_string(), + text_elements: Vec::new(), + }], + client_id: None + }, + TurnInput::ResponseItem(ResponseItem::from(communication.to_response_input_item())), ], ); } @@ -9443,19 +9645,22 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() { assert!(output.tool_future.is_some()); assert_eq!( sess.input_queue.get_pending_input(&sess.active_turn).await, - vec![TurnInput::ResponseInputItem( + vec![TurnInput::ResponseItem(ResponseItem::from( communication.to_response_input_item() - )], + ))], ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn abort_review_task_emits_exited_then_aborted_and_records_history() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let input = vec![TurnInput::UserInput(vec![UserInput::Text { - text: "start review".to_string(), - text_elements: Vec::new(), - }])]; + let input = vec![TurnInput::UserInput { + content: vec![UserInput::Text { + text: "start review".to_string(), + text_elements: Vec::new(), + }], + client_id: None, + }]; sess.spawn_task(Arc::clone(&tc), input, ReviewTask::new()) .await; @@ -9503,8 +9708,7 @@ async fn abort_review_task_emits_exited_then_aborted_and_records_history() { ); let history = sess.clone_history().await; - // The `` marker is silent in the event stream, so verify it is still - // recorded in history for the model. + // Verify the `` marker is still recorded in history for the model. assert!( history.raw_items().iter().any(|item| { let ResponseItem::Message { role, content, .. } = item else { @@ -10133,6 +10337,76 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { ExecApprovalRequirement::Skip { .. } )); } + +#[cfg(unix)] +#[tokio::test] +async fn shell_tool_cancellation_waits_for_runtime_cleanup() -> anyhow::Result<()> { + let session = make_session_with_config(|config| { + let cwd = config.cwd.clone(); + config + .permissions + .set_legacy_sandbox_policy(SandboxPolicy::DangerFullAccess, cwd.as_path()) + .expect("test setup should allow sandbox policy"); + }) + .await?; + let turn_context = session.new_default_turn().await; + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let temp_dir = tempfile::TempDir::new()?; + let ready_marker = temp_dir.path().join("ready"); + let cleanup_marker = temp_dir.path().join("cleanup"); + // Interrupt after the shell starts, then verify dispatch waits for its TERM cleanup trap. + let command = format!( + r#"trap 'printf cleaned > "{}"; exit 0' TERM +printf ready > "{}" +while :; do sleep 1; done"#, + cleanup_marker.display(), + ready_marker.display(), + ); + let item = ResponseItem::FunctionCall { + id: None, + name: "shell_command".to_string(), + namespace: None, + arguments: serde_json::json!({ + "command": command, + "timeout_ms": 60_000, + }) + .to_string(), + call_id: "shell-cleanup-call".to_string(), + }; + let call = ToolRouter::build_tool_call(item)? + .expect("shell command response item should build a tool call"); + let cancellation_token = CancellationToken::new(); + let cancellation_tx = cancellation_token.clone(); + let handle = tokio::spawn( + test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)) + .handle_tool_call(call, cancellation_token), + ); + + let mut ready = false; + for _ in 0..50 { + if ready_marker.exists() { + ready = true; + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + if !ready { + cancellation_tx.cancel(); + let _ = timeout(Duration::from_secs(5), handle).await; + anyhow::bail!("shell command should reach the ready marker"); + } + + cancellation_tx.cancel(); + timeout(Duration::from_secs(5), handle) + .await + .expect("cancelled shell tool should finish promptly") + .expect("shell tool task should join") + .expect("cancelled shell tool should return a response item"); + assert_eq!(std::fs::read_to_string(cleanup_marker)?, "cleaned"); + Ok(()) +} + #[tokio::test] async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() { use crate::sandboxing::SandboxPermissions; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 82fa35f108..8219c19019 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -145,7 +145,10 @@ pub(crate) async fn run_turn( // diffs/full reinjection + user input) and trigger compaction preemptively // when they would push the thread over the compaction threshold. if let Err(err) = run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await { - if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + let error = err.to_codex_protocol_error(); + sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) + .await; + if error == CodexErrorInfo::UsageLimitExceeded && let Err(err) = sess .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { turn_context: turn_context.as_ref(), @@ -234,7 +237,10 @@ pub(crate) async fn run_turn( .for_prompt(&turn_context.model_info.input_modalities) }; - let turn_metadata_header = turn_context.turn_metadata_state.current_header_value(); + let window_id = sess.services.model_client.current_window_id(); + let turn_metadata_header = turn_context + .turn_metadata_state + .current_header_value_for_model_request(&window_id); match run_sampling_request( Arc::clone(&sess), Arc::clone(&turn_context), @@ -292,7 +298,10 @@ pub(crate) async fn run_turn( ) .await { - if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + let error = err.to_codex_protocol_error(); + sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) + .await; + if error == CodexErrorInfo::UsageLimitExceeded && let Err(err) = sess .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { turn_context: turn_context.as_ref(), @@ -371,17 +380,23 @@ pub(crate) async fn run_turn( } } + let error = CodexErrorInfo::BadRequest; + sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) + .await; let event = EventMsg::Error(ErrorEvent { message: "Invalid image in your last message. Please remove it and try again." .to_string(), - codex_error_info: Some(CodexErrorInfo::BadRequest), + codex_error_info: Some(error), }); sess.send_event(&turn_context, event).await; break; } Err(e) => { info!("Turn error: {e:#}"); - if e.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + let error = e.to_codex_protocol_error(); + sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) + .await; + if error == CodexErrorInfo::UsageLimitExceeded && let Err(err) = sess .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { turn_context: turn_context.as_ref(), @@ -414,7 +429,7 @@ async fn run_hooks_and_record_inputs( blocked_input = true; record_additional_contexts(sess, turn_context, hook_outcome.additional_contexts).await; } else { - if matches!(input_item, TurnInput::UserInput(items) if !items.is_empty()) { + if matches!(input_item, TurnInput::UserInput { content, .. } if !content.is_empty()) { accepted_user_input = true; } record_pending_input( @@ -442,8 +457,8 @@ async fn build_skills_and_plugins( let user_input = input .iter() .filter_map(|item| match item { - TurnInput::UserInput(content) => Some(content.as_slice()), - TurnInput::ResponseInputItem(_) => None, + TurnInput::UserInput { content, .. } => Some(content.as_slice()), + TurnInput::ResponseItem(_) => None, }) .flatten() .cloned() @@ -595,8 +610,8 @@ async fn track_turn_resolved_config_analytics( num_input_images: input .iter() .filter_map(|item| match item { - TurnInput::UserInput(content) => Some(content.as_slice()), - TurnInput::ResponseInputItem(_) => None, + TurnInput::UserInput { content, .. } => Some(content.as_slice()), + TurnInput::ResponseItem(_) => None, }) .flatten() .filter(|item| { @@ -1006,12 +1021,25 @@ async fn run_sampling_request( clippy::await_holding_invalid_type, reason = "tool router construction reads through the session-owned manager guard" )] +#[instrument(level = "trace", + skip_all, + fields( + turn_id = %turn_context.sub_id, + model = %turn_context.model_info.slug, + apps_enabled = turn_context.apps_enabled() + ) +)] pub(crate) async fn built_tools( sess: &Session, turn_context: &TurnContext, cancellation_token: &CancellationToken, ) -> CodexResult> { - let mcp_connection_manager = sess.services.mcp_connection_manager.read().await; + let mcp_connection_manager = sess + .services + .mcp_connection_manager + .read() + .instrument(trace_span!("read_mcp_connection_manager")) + .await; let has_mcp_servers = mcp_connection_manager.has_servers(); let all_mcp_tools = mcp_connection_manager .list_all_tools() diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index df1502274b..dba5d16a75 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -322,10 +322,12 @@ impl TurnContext { } pub(crate) fn to_turn_context_item(&self) -> TurnContextItem { + let workspace_roots = self.config.effective_workspace_roots(); TurnContextItem { turn_id: Some(self.sub_id.clone()), #[allow(deprecated)] cwd: self.cwd.to_path_buf(), + workspace_roots: (!workspace_roots.is_empty()).then_some(workspace_roots), current_date: self.current_date.clone(), timezone: self.timezone.clone(), approval_policy: self.approval_policy.value(), diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index ca21e3ea72..0669e54469 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -256,9 +256,10 @@ async fn schedule_startup_prewarm_inner( build_prompt_started_at.elapsed(), /*status*/ None, ); + let window_id = session.services.model_client.current_window_id(); let startup_turn_metadata_header = startup_turn_context .turn_metadata_state - .current_header_value(); + .current_header_value_for_prewarm(&window_id); let mut client_session = session.services.model_client.new_session(); let websocket_warmup_started_at = Instant::now(); client_session diff --git a/codex-rs/core/src/tasks/lifecycle.rs b/codex-rs/core/src/tasks/lifecycle.rs index 8b934175cf..782cc8c7de 100644 --- a/codex-rs/core/src/tasks/lifecycle.rs +++ b/codex-rs/core/src/tasks/lifecycle.rs @@ -1,4 +1,5 @@ use codex_extension_api::ExtensionData; +use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TurnAbortReason; @@ -37,6 +38,23 @@ impl Session { } } + pub(crate) async fn emit_thread_idle_lifecycle_if_idle(&self) { + if self.active_turn.lock().await.is_some() + || self.input_queue.has_trigger_turn_mailbox_items().await + { + return; + } + + for contributor in self.services.extensions.thread_lifecycle_contributors() { + contributor + .on_thread_idle(codex_extension_api::ThreadIdleInput { + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + }) + .await; + } + } + pub(super) async fn emit_turn_abort_lifecycle( &self, reason: TurnAbortReason, @@ -53,4 +71,22 @@ impl Session { .await; } } + + pub(crate) async fn emit_turn_error_lifecycle( + &self, + turn_context: &TurnContext, + error: CodexErrorInfo, + ) { + for contributor in self.services.extensions.turn_lifecycle_contributors() { + contributor + .on_turn_error(codex_extension_api::TurnErrorInput { + turn_id: turn_context.sub_id.as_str(), + error: error.clone(), + session_store: &self.services.session_extension_data, + thread_store: &self.services.thread_extension_data, + turn_store: turn_context.extension_data.as_ref(), + }) + .await; + } + } } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index aa161c1274..38a8a96276 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -44,7 +44,6 @@ use codex_otel::TURN_TOKEN_USAGE_METRIC; use codex_otel::TURN_TOOL_CALL_METRIC; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; @@ -347,11 +346,7 @@ impl Session { { warn!("failed to apply goal runtime turn-start event: {err}"); } - let queued_response_items = self - .input_queue - .take_queued_response_items_for_next_turn() - .await; - let mailbox_items = self.input_queue.get_pending_input(&self.active_turn).await; + let pending_items = self.input_queue.get_pending_input(&self.active_turn).await; let turn_state = { let mut active = self.active_turn.lock().await; let turn = active.get_or_insert_with(ActiveTurn::default); @@ -359,11 +354,6 @@ impl Session { Arc::clone(&turn.turn_state) }; turn_state.lock().await.token_usage_at_turn_start = token_usage_at_turn_start.clone(); - let mut pending_items = queued_response_items - .into_iter() - .map(TurnInput::ResponseInputItem) - .collect::>(); - pending_items.extend(mailbox_items); self.input_queue .extend_pending_input_for_turn_state(turn_state.as_ref(), pending_items) .await; @@ -452,8 +442,7 @@ impl Session { /// Starts a regular turn when the session is idle and pending work is waiting. /// - /// Pending work currently includes queued next-turn items and mailbox mail marked with - /// `trigger_turn`. + /// Pending work currently includes mailbox mail marked with `trigger_turn`. /// /// This helper generates a fresh sub-id for the synthetic turn before delegating to the /// explicit-sub-id variant. @@ -465,18 +454,13 @@ impl Session { /// Starts a regular turn with the provided sub-id when pending work should wake an idle /// session. /// - /// The turn is created only when there are queued next-turn items or mailbox mail marked with - /// `trigger_turn`, and only if the session is currently idle. + /// The turn is created only when there is mailbox mail marked with `trigger_turn`, and only + /// if the session is currently idle. pub(crate) async fn maybe_start_turn_for_pending_work_with_sub_id( self: &Arc, sub_id: String, ) { - if !self - .input_queue - .has_queued_response_items_for_next_turn() - .await - && !self.input_queue.has_trigger_turn_mailbox_items().await - { + if !self.input_queue.has_trigger_turn_mailbox_items().await { return; } @@ -809,6 +793,7 @@ impl Session { { warn!("failed to apply goal runtime maybe-continue event: {err}"); } + self.emit_thread_idle_lifecycle_if_idle().await; } async fn take_active_turn(&self) -> Option { @@ -859,10 +844,11 @@ impl Session { InterruptedTurnHistoryMarker::from_config(task.turn_context.config.as_ref()), ) { - self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref()) - .await; - self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)]) - .await; + self.record_conversation_items( + task.turn_context.as_ref(), + std::slice::from_ref(&marker), + ) + .await; // Ensure the marker is durably visible before emitting TurnAborted: some clients // synchronously re-read the rollout on receipt of the abort event. if let Err(err) = self.flush_rollout().await { diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index ff7f38a7ea..3ed8677b3c 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -72,8 +72,8 @@ 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(_) => {} + TurnInput::UserInput { mut content, .. } => user_input.append(&mut content), + TurnInput::ResponseItem(_) => {} } } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 09284dc32a..6093dbbc64 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -38,8 +38,6 @@ use super::SessionTask; use super::SessionTaskContext; use crate::session::session::Session; use codex_protocol::models::PermissionProfile; -use codex_protocol::models::ResponseInputItem; -use codex_protocol::models::ResponseItem; const USER_SHELL_TIMEOUT_MS: u64 = 60 * 60 * 1000; // 1 hour @@ -363,30 +361,7 @@ async fn persist_user_shell_output( return; } - let response_input_item = match output_item { - ResponseItem::Message { - role, - content, - phase, - .. - } => ResponseInputItem::Message { - role, - content, - phase, - }, - _ => unreachable!("user shell command output record should always be a message"), - }; - - if let Err(items) = session - .inject_response_items(vec![response_input_item]) - .await - { - let response_items = items - .into_iter() - .map(ResponseItem::from) - .collect::>(); - session - .record_conversation_items(turn_context, &response_items) - .await; - } + session + .inject_no_new_turn(vec![output_item], Some(turn_context)) + .await; } diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index 48eec66c58..e491ca7413 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -30,7 +30,7 @@ use crate::unified_exec; static TEST_MODEL_PRESETS: Lazy> = Lazy::new(|| { let mut response = bundled_models_response() .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); - response.models.sort_by(|a, b| a.priority.cmp(&b.priority)); + response.models.sort_by_key(|model| model.priority); let mut presets: Vec = response.models.into_iter().map(Into::into).collect(); ModelPreset::mark_default_by_picker_visibility(&mut presets); presets diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index d209026b62..3eec8315b1 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -33,8 +33,6 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; -#[cfg(test)] -use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -1016,17 +1014,6 @@ impl ThreadManagerState { thread.submit(op).await } - #[cfg(test)] - /// Append a prebuilt message to a thread by ID outside the normal user-input path. - pub(crate) async fn append_message( - &self, - thread_id: ThreadId, - message: ResponseItem, - ) -> CodexResult { - let thread = self.get_thread(thread_id).await?; - thread.append_message(message).await - } - /// Remove a thread from the manager by ID, returning it when present. pub(crate) async fn remove_thread(&self, thread_id: &ThreadId) -> Option> { self.threads.write().await.remove(thread_id) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index c79a6859d7..76880eeb9e 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -1124,6 +1124,7 @@ fn multi_agent_v2_interrupted_marker_uses_developer_input_message() { fn completed_legacy_event_history_is_not_mid_turn() { let completed_history = InitialHistory::Forked(vec![ RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "hello".to_string(), images: None, text_elements: Vec::new(), @@ -1152,6 +1153,7 @@ fn mixed_response_and_legacy_user_event_history_is_mid_turn() { let mixed_history = InitialHistory::Forked(vec![ RolloutItem::ResponseItem(user_msg("hello")), RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "hello".to_string(), images: None, text_elements: Vec::new(), diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index e20ee53d47..69e615c4fb 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -130,7 +130,8 @@ pub(crate) fn truncate_rollout_before_nth_user_message_from_start( /// Return a suffix of `items` that keeps the last `n_from_end` fork turns. /// -/// If fewer than or equal to `n_from_end` fork turns exist, this returns the full rollout. +/// If fewer than or equal to `n_from_end` fork turns exist, this keeps from the first fork-turn +/// boundary and still drops pre-turn startup context. pub(crate) fn truncate_rollout_to_last_n_fork_turns( items: &[RolloutItem], n_from_end: usize, @@ -140,11 +141,14 @@ pub(crate) fn truncate_rollout_to_last_n_fork_turns( } let fork_turn_positions = fork_turn_positions_in_rollout(items); - if fork_turn_positions.len() <= n_from_end { - return items.to_vec(); - } - - let keep_idx = fork_turn_positions[fork_turn_positions.len() - n_from_end]; + let Some(keep_idx) = fork_turn_positions + .len() + .checked_sub(n_from_end) + .map(|position| fork_turn_positions[position]) + .or_else(|| fork_turn_positions.first().copied()) + else { + return Vec::new(); + }; items[keep_idx..].to_vec() } diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index df370a0546..e0a8837165 100644 --- a/codex-rs/core/src/thread_rollout_truncation_tests.rs +++ b/codex-rs/core/src/thread_rollout_truncation_tests.rs @@ -29,6 +29,17 @@ fn assistant_msg(text: &str) -> ResponseItem { } } +fn developer_msg(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + phase: None, + } +} + fn inter_agent_msg(text: &str, trigger_turn: bool) -> ResponseItem { let communication = InterAgentCommunication::new( AgentPath::root(), @@ -197,6 +208,23 @@ fn truncates_rollout_to_last_n_fork_turns_counts_trigger_turn_messages() { ); } +#[test] +fn truncates_rollout_to_last_n_fork_turns_drops_startup_prefix_even_when_under_limit() { + let rollout = vec![ + RolloutItem::ResponseItem(developer_msg("startup developer context")), + RolloutItem::ResponseItem(user_msg("current task")), + RolloutItem::ResponseItem(assistant_msg("answer")), + ]; + + let truncated = truncate_rollout_to_last_n_fork_turns(&rollout, /*n_from_end*/ 2); + let expected = rollout[1..].to_vec(); + + assert_eq!( + serde_json::to_value(&truncated).unwrap(), + serde_json::to_value(&expected).unwrap() + ); +} + #[test] fn truncates_rollout_to_last_n_fork_turns_applies_thread_rollback_markers() { let rollout = vec![ diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index 09d58ea8f3..abf4b9bbab 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -13,7 +13,7 @@ use codex_code_mode::CodeModeTurnHost; use codex_code_mode::RuntimeResponse; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; -use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; use serde_json::Value as JsonValue; use tokio_util::sync::CancellationToken; @@ -134,7 +134,7 @@ impl CodeModeTurnHost for CoreTurnHost { } self.exec .session - .inject_response_items(vec![ResponseInputItem::CustomToolCallOutput { + .inject_if_running(vec![ResponseItem::CustomToolCallOutput { call_id, name: Some(PUBLIC_TOOL_NAME.to_string()), output: FunctionCallOutputPayload::from_text(text), diff --git a/codex-rs/core/src/tools/code_mode/response_adapter.rs b/codex-rs/core/src/tools/code_mode/response_adapter.rs index 133d6e2fc1..e20cf6a071 100644 --- a/codex-rs/core/src/tools/code_mode/response_adapter.rs +++ b/codex-rs/core/src/tools/code_mode/response_adapter.rs @@ -17,6 +17,8 @@ impl IntoProtocol for CodeModeImageDetail { fn into_protocol(self) -> ImageDetail { let value = self; match value { + CodeModeImageDetail::Auto => ImageDetail::Auto, + CodeModeImageDetail::Low => ImageDetail::Low, CodeModeImageDetail::High => ImageDetail::High, CodeModeImageDetail::Original => ImageDetail::Original, } diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 8c9f55c460..d04a6c520e 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -1,11 +1,18 @@ use std::sync::Arc; +use std::sync::Weak; +use codex_protocol::items::TurnItem; use codex_tools::ConversationHistory; +use codex_tools::ExtensionTurnItem; use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolName; use codex_tools::ToolSpec; +use codex_tools::TurnItemEmissionFuture; +use codex_tools::TurnItemEmitter; use crate::function_tool::FunctionCallError; +use crate::session::session::Session; +use crate::session::turn_context::TurnContext; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -52,6 +59,39 @@ impl CoreToolRuntime for ExtensionToolAdapter { } } +struct CoreTurnItemEmitter { + session: Weak, + turn: Weak, +} + +fn extension_turn_item(item: ExtensionTurnItem) -> TurnItem { + match item { + ExtensionTurnItem::WebSearch(item) => TurnItem::WebSearch(item), + } +} + +impl TurnItemEmitter for CoreTurnItemEmitter { + fn emit_started<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a> { + Box::pin(async move { + let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { + return; + }; + let item = extension_turn_item(item); + session.emit_turn_item_started(turn.as_ref(), &item).await; + }) + } + + fn emit_completed<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a> { + Box::pin(async move { + let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { + return; + }; + let item = extension_turn_item(item); + session.emit_turn_item_completed(turn.as_ref(), item).await; + }) + } +} + async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { let conversation_history = ConversationHistory::new(invocation.session.clone_history().await.into_raw_items()); @@ -61,6 +101,10 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { tool_name: invocation.tool_name.clone(), truncation_policy: invocation.turn.truncation_policy, conversation_history, + turn_item_emitter: Arc::new(CoreTurnItemEmitter { + session: Arc::downgrade(&invocation.session), + turn: Arc::downgrade(&invocation.turn), + }), payload: invocation.payload.clone(), } } @@ -69,8 +113,13 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { mod tests { use std::sync::Arc; + use codex_protocol::items::TurnItem; + use codex_protocol::items::WebSearchItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; + use codex_protocol::models::WebSearchAction; + use codex_protocol::protocol::EventMsg; + use codex_tools::ExtensionTurnItem; use pretty_assertions::assert_eq; use serde_json::json; use tokio::sync::Mutex; @@ -147,6 +196,16 @@ mod tests { &self, call: codex_tools::ToolCall, ) -> Result, codex_tools::FunctionCallError> { + let item = ExtensionTurnItem::WebSearch(WebSearchItem { + id: call.call_id.clone(), + query: "rust trait object".to_string(), + action: WebSearchAction::Search { + query: Some("rust trait object".to_string()), + queries: None, + }, + }); + call.turn_item_emitter.emit_started(item.clone()).await; + call.turn_item_emitter.emit_completed(item).await; *self.captured_call.lock().await = Some(call); Ok(Box::new(codex_tools::JsonToolOutput::new( json!({ "ok": true }), @@ -191,12 +250,14 @@ mod tests { } #[tokio::test] - async fn passes_turn_fields_to_extension_call() { + async fn passes_turn_fields_and_scoped_turn_item_emitter_to_extension_call() { let captured_call = Arc::new(Mutex::new(None)); let handler = ExtensionToolAdapter::new(Arc::new(CapturingExtensionExecutor { captured_call: Arc::clone(&captured_call), })); - let (session, turn) = crate::session::tests::make_session_and_context().await; + let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await; + let weak_session = Arc::downgrade(&session); + let weak_turn = Arc::downgrade(&turn); let turn_id = turn.sub_id.clone(); let truncation_policy = turn.truncation_policy; let history_item = ResponseItem::Message { @@ -208,11 +269,16 @@ mod tests { phase: None, }; session - .record_into_history(std::slice::from_ref(&history_item), &turn) + .record_conversation_items(&turn, std::slice::from_ref(&history_item)) .await; + let raw_history_event = rx.recv().await.expect("history raw response item event"); + let EventMsg::RawResponseItem(raw_history_item) = raw_history_event.msg else { + panic!("expected raw response item event"); + }; + assert_eq!(raw_history_item.item, history_item); let invocation = ToolInvocation { - session: session.into(), - turn: turn.into(), + session, + turn, cancellation_token: tokio_util::sync::CancellationToken::new(), tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), call_id: "call-extension".to_string(), @@ -228,6 +294,8 @@ mod tests { .expect("extension call should succeed"); let captured_call = captured_call.lock().await.clone().expect("captured call"); + assert!(weak_session.upgrade().is_none()); + assert!(weak_turn.upgrade().is_none()); assert_eq!(captured_call.turn_id, turn_id); assert_eq!(captured_call.call_id, "call-extension"); assert_eq!( @@ -245,5 +313,43 @@ mod tests { } payload => panic!("expected function payload, got {payload:?}"), } + + let started = rx.recv().await.expect("item started event"); + let EventMsg::ItemStarted(started) = started.msg else { + panic!("expected item started event"); + }; + let TurnItem::WebSearch(started_item) = started.item else { + panic!("expected web search item"); + }; + let begin = rx.recv().await.expect("legacy web search begin event"); + let EventMsg::WebSearchBegin(begin) = begin.msg else { + panic!("expected legacy web search begin event"); + }; + let completed = rx.recv().await.expect("item completed event"); + let EventMsg::ItemCompleted(completed) = completed.msg else { + panic!("expected item completed event"); + }; + let TurnItem::WebSearch(completed_item) = completed.item else { + panic!("expected web search item"); + }; + let end = rx.recv().await.expect("legacy web search end event"); + let EventMsg::WebSearchEnd(end) = end.msg else { + panic!("expected legacy web search end event"); + }; + + let expected = WebSearchItem { + id: "call-extension".to_string(), + query: "rust trait object".to_string(), + action: WebSearchAction::Search { + query: Some("rust trait object".to_string()), + queries: None, + }, + }; + assert_eq!(started_item, expected); + assert_eq!(completed_item, expected); + assert_eq!(begin.call_id, expected.id); + assert_eq!(end.call_id, expected.id); + assert_eq!(end.query, expected.query); + assert_eq!(end.action, expected.action); } } diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index 09eba1730d..8f18d5b55e 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -522,19 +522,13 @@ mod tests { callable_name: tool_name.to_string(), callable_namespace: callable_namespace.to_string(), namespace_description: None, - tool: rmcp::model::Tool { - name: tool_name.to_string().into(), - title: None, - description: None, - input_schema: Arc::new(rmcp::model::object(serde_json::json!({ + tool: rmcp::model::Tool::new_with_raw( + tool_name.to_string(), + None, + Arc::new(rmcp::model::object(serde_json::json!({ "type": "object", }))), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + ), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs index a0996fb37b..b1e82aa8a6 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs @@ -82,10 +82,9 @@ impl ToolExecutor for ListMcpResourceTemplatesHandler { let payload_result: Result = async { if let Some(server_name) = server.clone() { - let params = cursor.clone().map(|value| PaginatedRequestParams { - meta: None, - cursor: Some(value), - }); + let params = cursor + .clone() + .map(|value| PaginatedRequestParams::default().with_cursor(Some(value))); let result = session .list_resource_templates(&server_name, params) .await diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs index b9699b330e..c2cdec8b15 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs @@ -82,10 +82,9 @@ impl ToolExecutor for ListMcpResourcesHandler { let payload_result: Result = async { if let Some(server_name) = server.clone() { - let params = cursor.clone().map(|value| PaginatedRequestParams { - meta: None, - cursor: Some(value), - }); + let params = cursor + .clone() + .map(|value| PaginatedRequestParams::default().with_cursor(Some(value))); let result = session .list_resources(&server_name, params) .await diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs index d1b73322c0..126c5d85e8 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs @@ -78,13 +78,7 @@ impl ToolExecutor for ReadMcpResourceHandler { let payload_result: Result = async { let result = session - .read_resource( - &server, - ReadResourceRequestParams { - meta: None, - uri: uri.clone(), - }, - ) + .read_resource(&server, ReadResourceRequestParams::new(uri.clone())) .await .map_err(|err| { FunctionCallError::RespondToModel(format!("resources/read failed: {err:#}")) diff --git a/codex-rs/core/src/tools/handlers/mcp_search_tests.rs b/codex-rs/core/src/tools/handlers/mcp_search_tests.rs index 588254ae27..8ddfa93d84 100644 --- a/codex-rs/core/src/tools/handlers/mcp_search_tests.rs +++ b/codex-rs/core/src/tools/handlers/mcp_search_tests.rs @@ -50,11 +50,10 @@ fn tool_info() -> ToolInfo { callable_name: "_create_event".to_string(), callable_namespace: "mcp__calendar__".to_string(), namespace_description: Some("Plan events.".to_string()), - tool: rmcp::model::Tool { - name: "createEvent".to_string().into(), - title: Some("Create event".to_string()), - description: Some("Create a calendar event.".to_string().into()), - input_schema: Arc::new(rmcp::model::object(json!({ + tool: rmcp::model::Tool::new( + "createEvent", + "Create a calendar event.", + Arc::new(rmcp::model::object(json!({ "type": "object", "properties": { "start_time": { "type": "string" }, @@ -62,12 +61,8 @@ fn tool_info() -> ToolInfo { }, "additionalProperties": false }))), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + ) + .with_title("Create event"), connector_id: None, connector_name: Some("Calendar".to_string()), plugin_display_names: vec![" Calendar plugin ".to_string(), " ".to_string()], diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs index de069854f1..bcfa5231b9 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -1,6 +1,7 @@ use super::*; use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v1; use crate::turn_timing::now_unix_timestamp_ms; +use codex_protocol::error::CodexErr; use codex_tools::ToolSpec; pub(crate) struct Handler; @@ -36,11 +37,9 @@ async fn handle_close_agent( let arguments = function_arguments(payload)?; let args: CloseAgentArgs = parse_arguments(&arguments)?; let agent_id = parse_agent_id_target(&args.target)?; - let receiver_agent = session - .services - .agent_control - .get_agent_metadata(agent_id) - .unwrap_or_default(); + let receiver_agent = session.services.agent_control.get_agent_metadata(agent_id); + let known_agent = receiver_agent.is_some(); + let receiver_agent = receiver_agent.unwrap_or_default(); session .send_event( &turn, @@ -60,6 +59,9 @@ async fn handle_close_agent( .await { Ok(mut status_rx) => status_rx.borrow_and_update().clone(), + Err(CodexErr::ThreadNotFound(_)) if known_agent => { + session.services.agent_control.get_status(agent_id).await + } Err(err) => { let status = session.services.agent_control.get_status(agent_id).await; session diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 4f459a0d0e..f1e7bcd429 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -105,7 +105,7 @@ pub(crate) fn build_wait_agent_statuses( status: status.clone(), }) .collect::>(); - extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + extras.sort_by_key(|entry| entry.thread_id.to_string()); entries.extend(extras); entries } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 68b19adea9..164d5410e0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -52,6 +52,7 @@ use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::user_input::UserInput; +use codex_state::DirectionalThreadSpawnEdgeStatus; use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use serde::Deserialize; @@ -3751,6 +3752,126 @@ async fn multi_agent_v2_close_agent_accepts_task_name_target() { ); } +#[tokio::test] +async fn multi_agent_v2_close_agent_reaps_stale_task_name_target() { + let (mut session, mut turn) = make_session_and_context().await; + let mut config = (*turn.config).clone(); + config.agent_max_threads = Some(1); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow sqlite"); + let state_db = init_state_db(&config) + .await + .expect("sqlite state db should initialize"); + 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(), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + Some(state_db.clone()), + ); + let root = manager + .start_thread(config.clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + turn.config = Arc::new(config.clone()); + + let session = Arc::new(session); + let turn = Arc::new(turn); + SpawnAgentHandlerV2::default() + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "task_name": "worker" + })), + )) + .await + .expect("spawn_agent should succeed"); + + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker path should resolve"); + let stale_thread = manager + .remove_thread(&agent_id) + .await + .expect("worker thread should be loaded before removal"); + stale_thread + .submit(Op::Shutdown {}) + .await + .expect("removed worker thread should still accept shutdown"); + stale_thread.wait_until_terminated().await; + + let output = CloseAgentHandlerV2 + .handle(invocation( + session.clone(), + turn.clone(), + "close_agent", + function_payload(json!({"target": "worker"})), + )) + .await + .expect("close_agent should reap stale v2 task names"); + let (content, success) = expect_text_output(output); + let result: close_agent::CloseAgentResult = + serde_json::from_str(&content).expect("close_agent result should be json"); + assert_eq!(result.previous_status, AgentStatus::NotFound); + assert_eq!(success, Some(true)); + + let open_children = state_db + .list_thread_spawn_children_with_status( + root.thread_id, + DirectionalThreadSpawnEdgeStatus::Open, + ) + .await + .expect("open children should load"); + assert_eq!(open_children, Vec::::new()); + let closed_children = state_db + .list_thread_spawn_children_with_status( + root.thread_id, + DirectionalThreadSpawnEdgeStatus::Closed, + ) + .await + .expect("closed children should load"); + assert_eq!(closed_children, vec![agent_id]); + + SpawnAgentHandlerV2::default() + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo again", + "task_name": "replacement" + })), + )) + .await + .expect("spawn_agent should succeed after stale close releases the slot"); + let replacement_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "replacement") + .await + .expect("replacement path should resolve"); + let _ = session + .services + .agent_control + .shutdown_live_agent(replacement_id) + .await + .expect("replacement should shut down"); +} + #[tokio::test] async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { let (mut session, mut turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs index 088e531062..15502572a5 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs @@ -1,6 +1,7 @@ use super::*; use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v2; use crate::turn_timing::now_unix_timestamp_ms; +use codex_protocol::error::CodexErr; use codex_tools::ToolSpec; pub(crate) struct Handler; @@ -36,11 +37,9 @@ async fn handle_close_agent( let arguments = function_arguments(payload)?; let args: CloseAgentArgs = parse_arguments(&arguments)?; let agent_id = resolve_agent_target(&session, &turn, &args.target).await?; - let receiver_agent = session - .services - .agent_control - .get_agent_metadata(agent_id) - .unwrap_or_default(); + let receiver_agent = session.services.agent_control.get_agent_metadata(agent_id); + let known_agent = receiver_agent.is_some(); + let receiver_agent = receiver_agent.unwrap_or_default(); if receiver_agent .agent_path .as_ref() @@ -69,6 +68,9 @@ async fn handle_close_agent( .await { Ok(mut status_rx) => status_rx.borrow_and_update().clone(), + Err(CodexErr::ThreadNotFound(_)) if known_agent => { + session.services.agent_control.get_status(agent_id).await + } Err(err) => { let status = session.services.agent_control.get_status(agent_id).await; session diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 09eb29b1e4..c890e0d8da 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -2,6 +2,7 @@ use codex_features::Feature; use codex_protocol::models::ShellCommandToolCallParams; use serde_json::Value as JsonValue; use std::sync::Arc; +use tokio_util::sync::CancellationToken; use crate::exec::ExecParams; use crate::exec_policy::ExecApprovalRequest; @@ -44,6 +45,7 @@ fn shell_command_payload_command(payload: &ToolPayload) -> Option { struct RunExecLikeArgs { tool_name: ToolName, exec_params: ExecParams, + cancellation_token: CancellationToken, hook_command: String, shell_type: Option, additional_permissions: Option, @@ -59,6 +61,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result for ShellCommandHandler { let ToolInvocation { session, turn, + cancellation_token, tracker, call_id, payload, @@ -188,6 +189,7 @@ impl ToolExecutor for ShellCommandHandler { run_exec_like(RunExecLikeArgs { tool_name, exec_params, + cancellation_token, hook_command: params.command, shell_type, additional_permissions: params.additional_permissions.clone(), @@ -208,6 +210,10 @@ impl CoreToolRuntime for ShellCommandHandler { matches!(payload, ToolPayload::Function { .. }) } + fn waits_for_runtime_cancellation(&self) -> bool { + true + } + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { shell_command_payload_command(&invocation.payload).map(|command| PreToolUsePayload { tool_name: HookToolName::bash(), diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index 107723aa15..442d77ce8d 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -258,21 +258,15 @@ mod tests { callable_name: tool_name.to_string(), callable_namespace: format!("mcp__{server_name}"), namespace_description: None, - tool: Tool { - name: tool_name.to_string().into(), - title: None, - description: Some(format!("{description_prefix} desktop tool").into()), - input_schema: Arc::new(rmcp::model::object(serde_json::json!({ + tool: Tool::new( + tool_name.to_string(), + format!("{description_prefix} desktop tool"), + Arc::new(rmcp::model::object(serde_json::json!({ "type": "object", "properties": {}, "additionalProperties": false, }))), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + ), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), diff --git a/codex-rs/core/src/tools/handlers/view_image_spec.rs b/codex-rs/core/src/tools/handlers/view_image_spec.rs index 662cd639a6..ed0b1021a5 100644 --- a/codex-rs/core/src/tools/handlers/view_image_spec.rs +++ b/codex-rs/core/src/tools/handlers/view_image_spec.rs @@ -40,7 +40,7 @@ pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: VIEW_IMAGE_TOOL_NAME.to_string(), - description: "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags)." + description: "View a local image file from the filesystem when visual inspection is needed. Use this for images already available on disk." .to_string(), strict: false, defer_loading: None, diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index bac6361329..7db1ec9643 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -92,6 +92,7 @@ impl ToolCallRuntime { let tracker = Arc::clone(&self.tracker); let lock = Arc::clone(&self.parallel_execution); let invocation_cancellation_token = cancellation_token.clone(); + let wait_for_runtime_cancellation = self.router.tool_waits_for_runtime_cancellation(&call); let started = Instant::now(); let abort_session = Arc::clone(&session); let abort_source = source.clone(); @@ -140,23 +141,35 @@ impl ToolCallRuntime { } else { let secs = started.elapsed().as_secs_f32().max(0.1); abort_dispatch_span.record("aborted", true); - handle.abort(); - match handle.await { - Ok(result) => result, - Err(err) if err.is_cancelled() => { - let response = Self::aborted_response(&call, secs); - notify_tool_aborted( - abort_session.as_ref(), - abort_turn.as_ref(), - call.call_id.as_str(), - &call.tool_name, - abort_source, - ) - .await; - Ok(response) + if wait_for_runtime_cancellation { + if terminal_outcome_reached.swap(true, Ordering::AcqRel) { + return handle.await.map_err(Self::tool_task_join_error)?; + } + // The abort owns the terminal outcome; await only so + // the runtime can finish process teardown. + match handle.await { + Ok(_) => {} + Err(err) if err.is_cancelled() => {} + Err(err) => return Err(Self::tool_task_join_error(err)), + } + } else { + handle.abort(); + match handle.await { + Ok(result) => return result, + Err(err) if err.is_cancelled() => {} + Err(err) => return Err(Self::tool_task_join_error(err)), } - Err(err) => Err(Self::tool_task_join_error(err)), } + let response = Self::aborted_response(&call, secs); + notify_tool_aborted( + abort_session.as_ref(), + abort_turn.as_ref(), + call.call_id.as_str(), + &call.tool_name, + abort_source, + ) + .await; + Ok(response) } }, } @@ -274,6 +287,85 @@ mod tests { impl CoreToolRuntime for ImmediateHandler {} + struct CancellationCleanupHandler { + tool_name: codex_tools::ToolName, + started: std::sync::Mutex>>, + cleanup_started: std::sync::Mutex>>, + allow_cleanup: Arc, + } + + #[async_trait::async_trait] + impl ToolExecutor for CancellationCleanupHandler { + fn tool_name(&self) -> codex_tools::ToolName { + self.tool_name.clone() + } + + fn spec(&self) -> codex_tools::ToolSpec { + codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool { + name: self.tool_name.name.clone(), + description: "Cancellation cleanup test tool.".to_string(), + strict: false, + defer_loading: None, + parameters: codex_tools::JsonSchema::default(), + output_schema: None, + }) + } + + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + let started = self + .started + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(started) = started { + let _ = started.send(()); + } + invocation.cancellation_token.cancelled().await; + let cleanup_started = self + .cleanup_started + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(cleanup_started) = cleanup_started { + let _ = cleanup_started.send(()); + } + self.allow_cleanup.notified().await; + Ok(Box::new(FunctionToolOutput::from_text( + "cleanup complete".to_string(), + Some(false), + ))) + } + } + + impl CoreToolRuntime for CancellationCleanupHandler { + fn waits_for_runtime_cancellation(&self) -> bool { + true + } + } + + struct FinishRecorder { + records: Arc>>, + } + + impl codex_extension_api::ToolLifecycleContributor for FinishRecorder { + fn on_tool_finish<'a>( + &'a self, + input: codex_extension_api::ToolFinishInput<'a>, + ) -> codex_extension_api::ToolLifecycleFuture<'a> { + let records = Arc::clone(&self.records); + let outcome = input.outcome; + Box::pin(async move { + records + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(outcome); + }) + } + } + struct BlockingFinishContributor { records: Arc>>, finish_started: std::sync::Mutex>>, @@ -375,4 +467,75 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn cancellation_waiting_for_runtime_cleanup_emits_only_aborted_lifecycle() + -> anyhow::Result<()> { + let (mut session, turn_context) = crate::session::tests::make_session_and_context().await; + let records = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut builder = + codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.tool_lifecycle_contributor(Arc::new(FinishRecorder { + records: Arc::clone(&records), + })); + session.services.extensions = Arc::new(builder.build()); + + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let tool_name = codex_tools::ToolName::plain("cleanup_tool"); + let (started_tx, started_rx) = oneshot::channel(); + let (cleanup_started_tx, cleanup_started_rx) = oneshot::channel(); + let allow_cleanup = Arc::new(Notify::new()); + let handler = Arc::new(CancellationCleanupHandler { + tool_name: tool_name.clone(), + started: std::sync::Mutex::new(Some(started_tx)), + cleanup_started: std::sync::Mutex::new(Some(cleanup_started_tx)), + allow_cleanup: Arc::clone(&allow_cleanup), + }) as Arc; + let router = Arc::new(ToolRouter::from_parts( + ToolRegistry::from_tools([handler]), + Vec::new(), + )); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let runtime = ToolCallRuntime::new(router, session, turn_context, tracker); + let cancellation_token = CancellationToken::new(); + let call = ToolCall { + tool_name, + call_id: "call-1".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + }; + + let response_task = + tokio::spawn(runtime.handle_tool_call(call, cancellation_token.clone())); + started_rx.await.expect("handler should start"); + cancellation_token.cancel(); + cleanup_started_rx + .await + .expect("handler should start cleanup"); + tokio::time::sleep(Duration::from_millis(10)).await; + allow_cleanup.notify_one(); + + let response = tokio::time::timeout(Duration::from_secs(1), response_task) + .await + .expect("timed out waiting for tool response") + .expect("tool response task should join")?; + let ResponseInputItem::FunctionCallOutput { output, .. } = response else { + anyhow::bail!("cancelled tool should return function output"); + }; + let FunctionCallOutputBody::Text(text) = output.body else { + anyhow::bail!("cancelled tool output should be text"); + }; + assert!(text.contains("aborted by user")); + + let actual = records + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .drain(..) + .collect::>(); + assert_eq!(vec![ToolCallOutcome::Aborted], actual); + + Ok(()) + } } diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index a08d5c7834..f0fdf424a9 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -57,6 +57,12 @@ pub(crate) trait CoreToolRuntime: ToolExecutor { ) } + /// Whether cancellation should let the handler finish teardown before the + /// host returns an aborted tool response. + fn waits_for_runtime_cancellation(&self) -> bool { + false + } + fn telemetry_tags<'a>( &'a self, _invocation: &'a ToolInvocation, @@ -284,6 +290,10 @@ impl CoreToolRuntime for ExposureOverride { self.handler.matches_kind(payload) } + fn waits_for_runtime_cancellation(&self) -> bool { + self.handler.waits_for_runtime_cancellation() + } + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { self.handler.pre_tool_use_payload(invocation) } @@ -381,6 +391,11 @@ impl ToolRegistry { Some(tool.supports_parallel_tool_calls()) } + pub(crate) fn waits_for_runtime_cancellation(&self, name: &ToolName) -> Option { + let tool = self.tool(name)?; + Some(tool.waits_for_runtime_cancellation()) + } + #[allow(dead_code)] pub(crate) async fn dispatch_any( &self, @@ -497,10 +512,12 @@ impl ToolRegistry { PreToolUseHookResult::Blocked(message) => { let err = FunctionCallError::RespondToModel(message); dispatch_trace.record_failed(&err); - if let Some(terminal_outcome_reached) = &terminal_outcome_reached { - terminal_outcome_reached.store(true, Ordering::Release); - } - notify_tool_finish(&invocation, ToolCallOutcome::Blocked).await; + notify_tool_finish_if_unclaimed( + &invocation, + terminal_outcome_reached.as_deref(), + ToolCallOutcome::Blocked, + ) + .await; return Err(err); } PreToolUseHookResult::Continue { @@ -511,11 +528,9 @@ impl ToolRegistry { } Err(err) => { dispatch_trace.record_failed(&err); - if let Some(terminal_outcome_reached) = &terminal_outcome_reached { - terminal_outcome_reached.store(true, Ordering::Release); - } - notify_tool_finish( + notify_tool_finish_if_unclaimed( &invocation, + terminal_outcome_reached.as_deref(), ToolCallOutcome::Failed { handler_executed: false, }, @@ -638,18 +653,21 @@ impl ToolRegistry { handler_executed: true, }, }; - if let Some(terminal_outcome_reached) = &terminal_outcome_reached { - terminal_outcome_reached.store(true, Ordering::Release); - } - notify_tool_finish(&invocation, lifecycle_outcome).await; + let finished = notify_tool_finish_if_unclaimed( + &invocation, + terminal_outcome_reached.as_deref(), + lifecycle_outcome, + ) + .await; - if let Err(err) = invocation - .session - .goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { - turn_context: invocation.turn.as_ref(), - tool_name: tool_name.name.as_str(), - }) - .await + if finished + && let Err(err) = invocation + .session + .goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { + turn_context: invocation.turn.as_ref(), + tool_name: tool_name.name.as_str(), + }) + .await { warn!("failed to account thread goal progress after tool call: {err}"); } @@ -676,6 +694,19 @@ impl ToolRegistry { } } +async fn notify_tool_finish_if_unclaimed( + invocation: &ToolInvocation, + terminal_outcome_reached: Option<&AtomicBool>, + outcome: ToolCallOutcome, +) -> bool { + if terminal_outcome_reached.is_some_and(|reached| reached.swap(true, Ordering::AcqRel)) { + return false; + } + + notify_tool_finish(invocation, outcome).await; + true +} + async fn handle_any_tool( tool: &dyn CoreToolRuntime, invocation: ToolInvocation, diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index cfd4776890..a095499ea0 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -86,6 +86,12 @@ impl ToolRouter { .unwrap_or(false) } + pub fn tool_waits_for_runtime_cancellation(&self, call: &ToolCall) -> bool { + self.registry + .waits_for_runtime_cancellation(&call.tool_name) + .unwrap_or(false) + } + #[instrument(level = "trace", skip_all, err)] pub fn build_tool_call(item: ResponseItem) -> Result, FunctionCallError> { match item { diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 9d685e53b6..cece7fc171 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -306,19 +306,13 @@ fn mcp_tool_info( callable_name: tool_name.to_string(), callable_namespace: callable_namespace.to_string(), namespace_description: None, - tool: rmcp::model::Tool { - name: tool_name.to_string().into(), - title: None, - description: Some("Test MCP tool".to_string().into()), - input_schema: Arc::new(rmcp::model::object(json!({ + tool: rmcp::model::Tool::new( + tool_name.to_string(), + "Test MCP tool", + Arc::new(rmcp::model::object(json!({ "type": "object", }))), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + ), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), @@ -338,7 +332,7 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow phase: None, }; session - .record_into_history(std::slice::from_ref(&history_item), &turn) + .record_conversation_items(&turn, std::slice::from_ref(&history_item)) .await; let router = ToolRouter::from_turn_context( diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index e5d34adf13..9e8a68acf0 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -98,6 +98,44 @@ pub(crate) fn with_shell_env_file_read_permission( additional_permissions } +#[cfg(unix)] +fn prepend_path_entry(env: &mut HashMap, path_entry: &str) -> String { + let updated_path = match env.get("PATH") { + Some(path) if !path.is_empty() => std::iter::once(path_entry) + .chain(path.split(':').filter(|entry| *entry != path_entry)) + .collect::>() + .join(":"), + _ => path_entry.to_string(), + }; + env.insert("PATH".to_string(), updated_path.clone()); + updated_path +} + +#[cfg(unix)] +pub(crate) fn prepend_zsh_fork_bin_to_path( + env: &mut HashMap, + shell_zsh_path: &Path, +) -> Option { + let zsh_bin_dir = shell_zsh_path + .parent() + .map(|path| path.to_string_lossy().to_string())?; + Some(prepend_path_entry(env, &zsh_bin_dir)) +} + +#[cfg(unix)] +pub(crate) fn apply_zsh_fork_path_prepend( + env: &mut HashMap, + explicit_env_overrides: &mut HashMap, + shell_zsh_path: &Path, +) { + let Some(updated_path) = prepend_zsh_fork_bin_to_path(env, shell_zsh_path) else { + return; + }; + // Snapshot wrapping restores explicit overrides after sourcing the shell + // snapshot, so capture this PATH override there as well. + explicit_env_overrides.insert("PATH".to_string(), updated_path); +} + pub(crate) fn disable_powershell_profile_for_elevated_windows_sandbox( command: &[String], shell_type: Option<&ShellType>, diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 69f941166d..f7c0f742f1 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -148,6 +148,48 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: Ok(()) } +#[cfg(unix)] +#[test] +fn apply_zsh_fork_path_prepend_uses_shell_parent() { + let mut env = HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]); + let mut explicit_env_overrides = HashMap::new(); + + apply_zsh_fork_path_prepend( + &mut env, + &mut explicit_env_overrides, + PathBuf::from("/package/codex-resources/zsh/bin/zsh").as_path(), + ); + + let expected = "/package/codex-resources/zsh/bin:/usr/bin:/bin"; + assert_eq!(env.get("PATH").map(String::as_str), Some(expected)); + assert_eq!( + explicit_env_overrides.get("PATH").map(String::as_str), + Some(expected) + ); +} + +#[cfg(unix)] +#[test] +fn apply_zsh_fork_path_prepend_moves_existing_shell_parent_to_front() { + let mut env = HashMap::from([( + "PATH".to_string(), + "/usr/bin:/package/codex-resources/zsh/bin:/bin:/package/codex-resources/zsh/bin" + .to_string(), + )]); + let mut explicit_env_overrides = HashMap::new(); + + apply_zsh_fork_path_prepend( + &mut env, + &mut explicit_env_overrides, + PathBuf::from("/package/codex-resources/zsh/bin/zsh").as_path(), + ); + + assert_eq!( + env.get("PATH").map(String::as_str), + Some("/package/codex-resources/zsh/bin:/usr/bin:/bin") + ); +} + #[test] fn explicit_escalation_keeps_user_proxy_env_without_codex_marker() { let env = HashMap::from([ @@ -987,6 +1029,58 @@ fn maybe_wrap_shell_lc_with_snapshot_applies_explicit_path_override() { assert_eq!(String::from_utf8_lossy(&output.stdout), "/worktree/bin"); } +#[cfg(unix)] +#[test] +fn maybe_wrap_shell_lc_with_snapshot_preserves_zsh_fork_path_prepend() { + let dir = tempdir().expect("create temp dir"); + let snapshot_path = dir.path().join("snapshot.sh"); + std::fs::write( + &snapshot_path, + "# Snapshot file\nexport PATH='/snapshot/bin'\n", + ) + .expect("write snapshot"); + let session_shell = shell_with_snapshot( + ShellType::Bash, + "/bin/bash", + snapshot_path.abs(), + dir.path().abs(), + ); + let command = vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "printf '%s' \"$PATH\"".to_string(), + ]; + let zsh_path = dir + .path() + .join("codex-resources") + .join("zsh") + .join("bin") + .join("zsh"); + let zsh_bin_dir = zsh_path.parent().expect("zsh path should have parent"); + let mut env = HashMap::from([("PATH".to_string(), "/worktree/bin".to_string())]); + let mut explicit_env_overrides = HashMap::new(); + apply_zsh_fork_path_prepend(&mut env, &mut explicit_env_overrides, zsh_path.as_path()); + let rewritten = maybe_wrap_shell_command_with_runtime_env( + &command, + &session_shell, + &dir.path().abs(), + /*env_file*/ None, + &explicit_env_overrides, + &env, + ); + let output = Command::new(&rewritten[0]) + .args(&rewritten[1..]) + .env("PATH", env.get("PATH").expect("PATH should be set")) + .output() + .expect("run rewritten command"); + + assert!(output.status.success(), "command failed: {output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + format!("{}:/worktree/bin", zsh_bin_dir.display()) + ); +} + #[test] fn maybe_wrap_shell_lc_with_snapshot_does_not_embed_override_values_in_argv() { let dir = tempdir().expect("create temp dir"); diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 4b94f0aa66..6612f3176b 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -20,6 +20,8 @@ use crate::shell::ShellType; use crate::tools::flat_tool_name; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; +#[cfg(unix)] +use crate::tools::runtimes::apply_zsh_fork_path_prepend; use crate::tools::runtimes::build_sandbox_command; use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sandbox; use crate::tools::runtimes::exec_env_for_sandbox_permissions; @@ -45,6 +47,7 @@ use codex_shell_command::powershell::prefix_powershell_script_with_utf8; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::BoxFuture; use std::collections::HashMap; +use tokio_util::sync::CancellationToken; #[derive(Clone, Debug)] pub struct ShellRequest { @@ -53,6 +56,7 @@ pub struct ShellRequest { pub hook_command: String, pub cwd: AbsolutePathBuf, pub timeout_ms: Option, + pub cancellation_token: CancellationToken, pub env: HashMap, pub explicit_env_overrides: HashMap, pub network: Option, @@ -233,13 +237,25 @@ impl ToolRuntime for ShellRuntime { let managed_network = managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions); let env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions); + let explicit_env_overrides = req.explicit_env_overrides.clone(); + #[cfg(unix)] + let (env, explicit_env_overrides) = { + let mut env = env; + let mut explicit_env_overrides = explicit_env_overrides; + if self.backend == ShellRuntimeBackend::ShellCommandZshFork + && let Some(shell_zsh_path) = ctx.session.services.shell_zsh_path.as_deref() + { + apply_zsh_fork_path_prepend(&mut env, &mut explicit_env_overrides, shell_zsh_path); + } + (env, explicit_env_overrides) + }; let shell_env_file_path = ctx.session.shell_env_file_path(); let command = maybe_wrap_shell_command_with_runtime_env( &req.command, session_shell.as_ref(), &req.cwd, shell_env_file_path, - &req.explicit_env_overrides, + &explicit_env_overrides, &env, ); let command = disable_powershell_profile_for_elevated_windows_sandbox( @@ -271,6 +287,7 @@ impl ToolRuntime for ShellRuntime { ); let command = build_sandbox_command(&command, &req.cwd, &env, additional_permissions)?; let mut expiration: crate::exec::ExecExpiration = req.timeout_ms.into(); + expiration = expiration.with_cancellation(req.cancellation_token.clone()); if let Some(cancellation) = attempt.network_denial_cancellation_token.clone() { expiration = expiration.with_cancellation(cancellation); } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 5c896bea9f..1132a2a0ba 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -16,6 +16,7 @@ use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; use crate::tools::runtimes::build_sandbox_command; use crate::tools::runtimes::exec_env_for_sandbox_permissions; +use crate::tools::runtimes::prepend_zsh_fork_bin_to_path; use crate::tools::runtimes::with_shell_env_file_read_permission; use crate::tools::sandboxing::PermissionRequestPayload; use crate::tools::sandboxing::SandboxAttempt; @@ -117,7 +118,8 @@ pub(super) async fn try_run_zsh_fork( return Ok(None); } - let env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions); + let mut env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions); + prepend_zsh_fork_bin_to_path(&mut env, shell_zsh_path); let additional_permissions = with_shell_env_file_read_permission( req.additional_permissions.clone(), ctx.session.shell_env_file_path(), diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 4f0db4f0b1..3bcaf3d513 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -17,6 +17,8 @@ use crate::shell::ShellType; use crate::tools::flat_tool_name; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; +#[cfg(unix)] +use crate::tools::runtimes::apply_zsh_fork_path_prepend; use crate::tools::runtimes::build_sandbox_command; use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sandbox; use crate::tools::runtimes::exec_env_for_sandbox_permissions; @@ -262,6 +264,19 @@ impl<'a> ToolRuntime for UnifiedExecRunt if let Some(network) = managed_network { network.apply_to_env(&mut env); } + let explicit_env_overrides = req.explicit_env_overrides.clone(); + #[cfg(unix)] + let explicit_env_overrides = { + let mut explicit_env_overrides = explicit_env_overrides; + if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode { + apply_zsh_fork_path_prepend( + &mut env, + &mut explicit_env_overrides, + zsh_fork_config.shell_zsh_path.as_path(), + ); + } + explicit_env_overrides + }; let environment_is_remote = req.environment.is_remote(); let shell_env_file_path = (!environment_is_remote) .then(|| ctx.session.shell_env_file_path()) @@ -274,7 +289,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt session_shell.as_ref(), &req.cwd, shell_env_file_path, - &req.explicit_env_overrides, + &explicit_env_overrides, &env, ) }; diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 2610bee95c..bb7baf4bd8 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -85,6 +85,8 @@ use std::sync::Arc; use tracing::warn; const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents."; +const IMAGE_GEN_NAMESPACE: &str = "image_gen"; +const IMAGEGEN_TOOL_NAME: &str = "imagegen"; type PlannedRuntime = Arc; @@ -257,7 +259,9 @@ fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec { }) { specs.push(web_search_tool); } - if image_generation_tool_enabled(turn_context) { + if image_generation_tool_enabled(turn_context) + && !standalone_image_generation_available(turn_context, context.extension_tool_executors) + { specs.push(create_image_generation_tool("png")); } specs @@ -316,21 +320,41 @@ fn agent_jobs_worker_tools_enabled(turn_context: &TurnContext) -> bool { } fn image_generation_tool_enabled(turn_context: &TurnContext) -> bool { + image_generation_runtime_enabled(turn_context) + && turn_context + .features + .get() + .enabled(Feature::ImageGeneration) +} + +fn image_generation_runtime_enabled(turn_context: &TurnContext) -> bool { turn_context .auth_manager .as_deref() .is_some_and(AuthManager::current_auth_uses_codex_backend) && turn_context.provider.capabilities().image_generation - && turn_context - .features - .get() - .enabled(Feature::ImageGeneration) && turn_context .model_info .input_modalities .contains(&InputModality::Image) } +fn standalone_image_generation_model_visible(turn_context: &TurnContext) -> bool { + image_generation_runtime_enabled(turn_context) + && turn_context.features.get().enabled(Feature::ImageGenExt) + && namespace_tools_enabled(turn_context) +} + +fn standalone_image_generation_available( + turn_context: &TurnContext, + extension_tools: &[Arc>], +) -> bool { + standalone_image_generation_model_visible(turn_context) + && extension_tools.iter().any(|executor| { + executor.tool_name() == ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME) + }) +} + fn wait_agent_timeout_options(turn_context: &TurnContext) -> WaitAgentTimeoutOptions { if multi_agent_v2_enabled(turn_context) { return WaitAgentTimeoutOptions { @@ -839,6 +863,11 @@ fn append_extension_tool_executors( for executor in executors.iter().cloned() { let tool_name = executor.tool_name(); + if tool_name == ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME) + && !standalone_image_generation_model_visible(turn_context) + { + continue; + } if !reserved_tool_names.insert(tool_name.clone()) { warn!("Skipping extension tool `{tool_name}`: tool already registered"); continue; diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 2eb6eea5d2..a274f934c4 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -302,21 +302,15 @@ fn mcp_tool(server: &str, namespace: &str, name: &str) -> ToolInfo { callable_name: name.to_string(), callable_namespace: namespace.to_string(), namespace_description: Some(format!("Tools from {server}.")), - tool: rmcp::model::Tool { - name: name.to_string().into(), - title: None, - description: Some(format!("{name} test tool").into()), - input_schema: Arc::new(rmcp::model::object(json!({ + tool: rmcp::model::Tool::new( + name.to_string(), + format!("{name} test tool"), + Arc::new(rmcp::model::object(json!({ "type": "object", "properties": {}, "additionalProperties": false, }))), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + ), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), @@ -526,7 +520,7 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { "read_mcp_resource", ]); - let missing_namespace_capability = probe_with( + let bedrock_namespace_capability = probe_with( |turn| { turn.model_info.supports_search_tool = true; use_bedrock_provider(turn); @@ -537,7 +531,7 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { }, ) .await; - missing_namespace_capability.assert_visible_lacks(&["tool_search"]); + bedrock_namespace_capability.assert_visible_contains(&["tool_search"]); let enabled = probe_with( |turn| { @@ -890,7 +884,7 @@ async fn multi_agent_v2_can_use_configured_tool_namespace() { } #[tokio::test] -async fn multi_agent_v2_namespace_is_ignored_without_provider_namespace_support() { +async fn multi_agent_v2_namespace_is_supported_by_bedrock_provider() { let plan = probe(|turn| { set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); update_config(turn, |config| { @@ -900,15 +894,15 @@ async fn multi_agent_v2_namespace_is_ignored_without_provider_namespace_support( }) .await; - plan.assert_visible_contains(&["spawn_agent", "send_message", "list_agents"]); - plan.assert_visible_lacks(&["agents"]); - assert!( - plan.registered_names - .contains(&ToolName::plain("spawn_agent").to_string()) - ); + plan.assert_visible_contains(&["agents"]); + plan.assert_visible_lacks(&["spawn_agent", "send_message", "list_agents"]); assert!( !plan .registered_names + .contains(&ToolName::plain("spawn_agent").to_string()) + ); + assert!( + plan.registered_names .contains(&ToolName::namespaced("agents", "spawn_agent").to_string()) ); } @@ -966,6 +960,16 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { .await; image_generation.assert_visible_contains(&["image_generation"]); + let extension_flag_without_imagegen_tool = probe(|turn| { + use_chatgpt_auth(turn); + set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); + set_feature(turn, Feature::ImageGenExt, /*enabled*/ true); + turn.model_info.input_modalities = vec![InputModality::Image]; + }) + .await; + extension_flag_without_imagegen_tool.assert_visible_contains(&["image_generation"]); + extension_flag_without_imagegen_tool.assert_visible_lacks(&["image_gen"]); + let live_web_search = probe(|turn| { set_web_search_mode(turn, WebSearchMode::Live); turn.model_info.web_search_tool_type = WebSearchToolType::TextAndImage; diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 52a714960a..4f9406d4b0 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -6,6 +6,11 @@ use std::sync::RwLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; +use codex_analytics::CompactionImplementation; +use codex_analytics::CompactionPhase; +use codex_analytics::CompactionReason; +use codex_analytics::CompactionStrategy; +use codex_analytics::CompactionTrigger; use codex_utils_string::to_ascii_json_string; use serde::Serialize; use serde_json::Value; @@ -27,12 +32,54 @@ const MODEL_KEY: &str = "model"; const REASONING_EFFORT_KEY: &str = "reasoning_effort"; const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms"; const USER_INPUT_REQUESTED_DURING_TURN_KEY: &str = "user_input_requested_during_turn"; +const REQUEST_KIND_KEY: &str = "request_kind"; +const COMPACTION_KEY: &str = "compaction"; +const WINDOW_ID_KEY: &str = "window_id"; pub(crate) struct McpTurnMetadataContext<'a> { pub(crate) model: &'a str, pub(crate) reasoning_effort: Option, } +/// Metadata present only on outbound model requests that perform compaction. +/// +/// These fields describe the operation at dispatch time. Post-response outcomes such as status, +/// error, duration, and token deltas remain in compaction analytics events. +#[derive(Clone, Copy, Debug, Serialize)] +pub(crate) struct CompactionTurnMetadata { + trigger: CompactionTrigger, + reason: CompactionReason, + implementation: CompactionImplementation, + phase: CompactionPhase, + strategy: CompactionStrategy, +} + +impl CompactionTurnMetadata { + pub(crate) fn new( + trigger: CompactionTrigger, + reason: CompactionReason, + implementation: CompactionImplementation, + phase: CompactionPhase, + ) -> Self { + Self { + trigger, + reason, + implementation, + phase, + strategy: CompactionStrategy::Memento, + } + } +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +enum TurnMetadataRequestKind { + Turn, + Prewarm, + Compaction, + Memory, +} + #[derive(Clone, Debug, Default)] struct WorkspaceGitMetadata { associated_remote_urls: Option>, @@ -68,8 +115,15 @@ impl From for TurnMetadataWorkspace { } } -#[derive(Clone, Debug, Serialize, Default)] +/// Base payload for the outbound model request `x-codex-turn-metadata` header. +/// +/// Turn-owned state populates identity fields, including optional fork lineage. A concrete +/// request kind is added at outbound model dispatch so turns, startup prewarm, and compaction +/// remain distinguishable. Detached memory requests are constructed as `memory` directly. +#[derive(Clone, Debug, Serialize)] pub(crate) struct TurnMetadataBag { + #[serde(default, skip_serializing_if = "Option::is_none")] + request_kind: Option, #[serde(default, skip_serializing_if = "Option::is_none")] session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -87,6 +141,41 @@ pub(crate) struct TurnMetadataBag { } impl TurnMetadataBag { + fn new( + request_kind: Option, + session_id: Option, + thread_id: Option, + forked_from_thread_id: Option, + thread_source: Option, + turn_id: Option, + sandbox: Option, + ) -> Self { + Self { + request_kind, + session_id, + thread_id, + forked_from_thread_id, + thread_source, + turn_id, + workspaces: BTreeMap::new(), + sandbox, + } + } + + fn with_workspace_git_metadata( + mut self, + repo_root: Option, + workspace_git_metadata: Option, + ) -> Self { + if let (Some(repo_root), Some(workspace_git_metadata)) = (repo_root, workspace_git_metadata) + && !workspace_git_metadata.is_empty() + { + self.workspaces + .insert(repo_root, workspace_git_metadata.into()); + } + self + } + fn to_header_value(&self) -> Option { to_ascii_json_string(self).ok() } @@ -117,6 +206,9 @@ fn merge_turn_metadata( | "turn_id" | TURN_STARTED_AT_UNIX_MS_KEY | "forked_from_thread_id" + | REQUEST_KIND_KEY + | COMPACTION_KEY + | WINDOW_ID_KEY ) { continue; } @@ -140,32 +232,24 @@ pub async fn build_turn_metadata_header( get_has_changes(cwd), ); let latest_git_commit_hash = head_commit_hash.map(|sha| sha.0); - if latest_git_commit_hash.is_none() - && associated_remote_urls.is_none() - && has_changes.is_none() - && sandbox.is_none() - { - return None; - } - - let workspace_git_metadata = WorkspaceGitMetadata { - associated_remote_urls, - latest_git_commit_hash, - has_changes, - }; - let mut metadata = TurnMetadataBag { - sandbox: sandbox.map(ToString::to_string), - ..Default::default() - }; - if let Some(repo_root) = repo_root - && !workspace_git_metadata.is_empty() - { - metadata - .workspaces - .insert(repo_root, workspace_git_metadata.into()); - } - - metadata.to_header_value() + TurnMetadataBag::new( + Some(TurnMetadataRequestKind::Memory), + /*session_id*/ None, + /*thread_id*/ None, + /*forked_from_thread_id*/ None, + /*thread_source*/ None, + /*turn_id*/ None, + sandbox.map(ToString::to_string), + ) + .with_workspace_git_metadata( + repo_root, + Some(WorkspaceGitMetadata { + associated_remote_urls, + latest_git_commit_hash, + has_changes, + }), + ) + .to_header_value() } #[derive(Clone, Debug)] @@ -173,7 +257,7 @@ pub(crate) struct TurnMetadataState { cwd: AbsolutePathBuf, repo_root: Option, base_metadata: TurnMetadataBag, - base_header: String, + base_header: Option, enriched_header: Arc>>, turn_started_at_unix_ms: Arc>>, responsesapi_client_metadata: Arc>>>, @@ -203,18 +287,16 @@ impl TurnMetadataState { ) .to_string(), ); - let base_metadata = TurnMetadataBag { - session_id: Some(session_id), - thread_id: Some(thread_id), + let base_metadata = TurnMetadataBag::new( + /*request_kind*/ None, + Some(session_id), + Some(thread_id), forked_from_thread_id, thread_source, - turn_id: Some(turn_id), + Some(turn_id), sandbox, - ..Default::default() - }; - let base_header = base_metadata - .to_header_value() - .unwrap_or_else(|| "{}".to_string()); + ); + let base_header = base_metadata.to_header_value(); Self { cwd, @@ -239,7 +321,7 @@ impl TurnMetadataState { { header } else { - self.base_header.clone() + self.base_header.clone()? }; let turn_started_at_unix_ms = *self .turn_started_at_unix_ms @@ -264,6 +346,7 @@ impl TurnMetadataState { ) -> Option { let header = self.current_header_value()?; let mut metadata = serde_json::from_str::>(&header).ok()?; + metadata.remove(REQUEST_KIND_KEY); metadata.insert( MODEL_KEY.to_string(), Value::String(context.model.to_string()), @@ -293,6 +376,52 @@ impl TurnMetadataState { Some(Value::Object(metadata)) } + fn current_header_value_for_model_request_kind( + &self, + window_id: &str, + request_kind: TurnMetadataRequestKind, + ) -> Option { + let header = self.current_header_value()?; + let mut metadata = serde_json::from_str::>(&header).ok()?; + metadata.insert( + REQUEST_KIND_KEY.to_string(), + serde_json::to_value(request_kind).ok()?, + ); + metadata.insert( + WINDOW_ID_KEY.to_string(), + Value::String(window_id.to_string()), + ); + to_ascii_json_string(&metadata).ok() + } + + pub(crate) fn current_header_value_for_model_request(&self, window_id: &str) -> Option { + self.current_header_value_for_model_request_kind(window_id, TurnMetadataRequestKind::Turn) + } + + pub(crate) fn current_header_value_for_prewarm(&self, window_id: &str) -> Option { + self.current_header_value_for_model_request_kind( + window_id, + TurnMetadataRequestKind::Prewarm, + ) + } + + pub(crate) fn current_header_value_for_compaction( + &self, + window_id: &str, + compaction: CompactionTurnMetadata, + ) -> Option { + let header = self.current_header_value_for_model_request_kind( + window_id, + TurnMetadataRequestKind::Compaction, + )?; + let mut metadata = serde_json::from_str::>(&header).ok()?; + metadata.insert( + COMPACTION_KEY.to_string(), + serde_json::to_value(compaction).ok()?, + ); + to_ascii_json_string(&metadata).ok() + } + pub(crate) fn mark_user_input_requested_during_turn(&self) { self.user_input_requested_during_turn .store(true, Ordering::Relaxed); @@ -335,13 +464,14 @@ impl TurnMetadataState { let Some(repo_root) = state.repo_root.clone() else { return; }; - if workspace_git_metadata.is_empty() { + + let enriched_metadata = state + .base_metadata + .clone() + .with_workspace_git_metadata(Some(repo_root), Some(workspace_git_metadata)); + if enriched_metadata.workspaces.is_empty() { return; } - let mut enriched_metadata = state.base_metadata.clone(); - enriched_metadata - .workspaces - .insert(repo_root, workspace_git_metadata.into()); if let Some(header_value) = enriched_metadata.to_header_value() { *state diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index a874b0db9b..a482392726 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -20,7 +20,7 @@ fn test_mcp_turn_metadata_context() -> McpTurnMetadataContext<'static> { } #[tokio::test] -async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() { +async fn build_turn_metadata_header_marks_detached_memory_without_turn_identity() { let temp_dir = TempDir::new().expect("temp dir"); let repo_path = temp_dir.path().join("repo-東京").abs(); std::fs::create_dir_all(&repo_path).expect("create repo"); @@ -64,6 +64,13 @@ async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() { assert!(header.is_ascii()); assert!(!header.contains("東京")); let parsed: Value = serde_json::from_str(&header).expect("valid json"); + assert_eq!(parsed["request_kind"].as_str(), Some("memory")); + assert!(parsed.get("session_id").is_none()); + assert!(parsed.get("thread_id").is_none()); + assert!(parsed.get("forked_from_thread_id").is_none()); + assert!(parsed.get("turn_id").is_none()); + assert!(parsed.get(WINDOW_ID_KEY).is_none()); + let expected_repo_path = repo_path.to_string_lossy().into_owned(); let actual_repo_path = parsed .get("workspaces") @@ -77,13 +84,25 @@ async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() { .and_then(|workspaces| workspaces.values().next()) .cloned() .expect("workspace"); - assert_eq!( workspace.get("has_changes").and_then(Value::as_bool), Some(false) ); } +#[tokio::test] +async fn build_turn_metadata_header_marks_memory_without_workspace_metadata() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = temp_dir.path().abs(); + + let header = build_turn_metadata_header(&cwd, /*sandbox*/ None) + .await + .expect("detached memory should emit its request kind"); + let parsed: Value = serde_json::from_str(&header).expect("valid json"); + + assert_eq!(parsed, serde_json::json!({"request_kind": "memory"})); +} + #[test] fn turn_metadata_state_uses_platform_sandbox_tag() { let temp_dir = TempDir::new().expect("temp dir"); @@ -109,6 +128,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { let thread_id = json.get("thread_id").and_then(Value::as_str); let thread_source = json.get("thread_source").and_then(Value::as_str); + assert!(json.get("request_kind").is_none()); let expected_sandbox = permission_profile_sandbox_tag( &permission_profile, WindowsSandboxLevel::Disabled, @@ -228,6 +248,7 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta( let meta = state .current_meta_value_for_mcp_request(test_mcp_turn_metadata_context()) .expect("turn metadata should be present"); + assert!(meta.get("request_kind").is_none()); assert_eq!(meta["model"].as_str(), Some("gpt-5.4")); assert_eq!(meta["reasoning_effort"].as_str(), Some("high")); @@ -367,7 +388,10 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( "forked_from_thread_id".to_string(), "client-supplied".to_string(), ), + ("turn_id".to_string(), "client-supplied".to_string()), + (WINDOW_ID_KEY.to_string(), "client-supplied".to_string()), ("thread_source".to_string(), "client-supplied".to_string()), + ("request_kind".to_string(), "client-supplied".to_string()), ( "turn_started_at_unix_ms".to_string(), "client-supplied".to_string(), @@ -392,14 +416,84 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( ); assert_eq!(json["thread_source"].as_str(), Some("user")); assert_eq!(json["turn_id"].as_str(), Some("turn-a")); + assert!(json.get("request_kind").is_none()); + assert!(json.get(WINDOW_ID_KEY).is_none()); assert_eq!( json["turn_started_at_unix_ms"].as_i64(), Some(1_700_000_000_123) ); + let model_request_header = state + .current_header_value_for_model_request("thread-a:1") + .expect("model request header"); + let model_request_json: Value = + serde_json::from_str(&model_request_header).expect("model request json"); + assert_eq!(model_request_json["request_kind"].as_str(), Some("turn")); + assert_eq!( + model_request_json[WINDOW_ID_KEY].as_str(), + Some("thread-a:1") + ); + let meta = state .current_meta_value_for_mcp_request(test_mcp_turn_metadata_context()) .expect("turn metadata should be present"); assert_eq!(meta["model"].as_str(), Some("gpt-5.4")); assert_eq!(meta["reasoning_effort"].as_str(), Some("high")); + assert!(meta.get(WINDOW_ID_KEY).is_none()); +} + +#[test] +fn turn_metadata_state_overlays_compaction_only_on_compaction_requests() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = temp_dir.path().abs(); + let permission_profile = PermissionProfile::read_only(); + let state = TurnMetadataState::new( + "session-a".to_string(), + "thread-a".to_string(), + /*forked_from_thread_id*/ None, + Some(ThreadSource::User), + "turn-a".to_string(), + cwd, + &permission_profile, + WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, + ); + state.set_responsesapi_client_metadata(HashMap::from([( + "compaction".to_string(), + "client-supplied".to_string(), + )])); + + let compact_header = state + .current_header_value_for_compaction( + "thread-a:2", + CompactionTurnMetadata::new( + CompactionTrigger::Auto, + CompactionReason::ContextLimit, + CompactionImplementation::ResponsesCompactionV2, + CompactionPhase::MidTurn, + ), + ) + .expect("compact header"); + let compact_json: Value = serde_json::from_str(&compact_header).expect("json"); + assert_eq!(compact_json["request_kind"].as_str(), Some("compaction")); + assert_eq!(compact_json["turn_id"].as_str(), Some("turn-a")); + assert_eq!(compact_json[WINDOW_ID_KEY].as_str(), Some("thread-a:2")); + assert_eq!( + compact_json["compaction"], + serde_json::json!({ + "trigger": "auto", + "reason": "context_limit", + "implementation": "responses_compaction_v2", + "phase": "mid_turn", + "strategy": "memento", + }) + ); + + let regular_header = state + .current_header_value_for_model_request("thread-a:3") + .expect("regular header"); + let regular_json: Value = serde_json::from_str(®ular_header).expect("json"); + assert_eq!(regular_json["request_kind"].as_str(), Some("turn")); + assert_eq!(regular_json[WINDOW_ID_KEY].as_str(), Some("thread-a:3")); + assert!(regular_json.get("compaction").is_none()); } diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 6cfb934d96..06b3e8c1d5 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -866,12 +866,6 @@ impl UnifiedExecProcessManager { #[cfg(target_os = "windows")] if request.sandbox == codex_sandboxing::SandboxType::WindowsRestrictedToken { - let sandbox_policy = request.compatibility_sandbox_policy(); - let policy_json = serde_json::to_string(&sandbox_policy).map_err(|err| { - UnifiedExecError::create_process(format!( - "failed to serialize Windows sandbox policy: {err}" - )) - })?; let codex_home = crate::config::find_codex_home().map_err(|err| { UnifiedExecError::create_process(format!( "windows sandbox: failed to resolve codex_home: {err}" @@ -923,7 +917,7 @@ impl UnifiedExecProcessManager { codex_protocol::config_types::WindowsSandboxLevel::RestrictedToken | codex_protocol::config_types::WindowsSandboxLevel::Disabled => { codex_windows_sandbox::spawn_windows_sandbox_session_legacy( - policy_json.as_str(), + &request.permission_profile, request.windows_sandbox_policy_cwd.as_path(), codex_home.as_ref(), request.command.clone(), diff --git a/codex-rs/core/src/windows_sandbox.rs b/codex-rs/core/src/windows_sandbox.rs index 1494ce262e..b2ac8bf1f0 100644 --- a/codex-rs/core/src/windows_sandbox.rs +++ b/codex-rs/core/src/windows_sandbox.rs @@ -8,7 +8,7 @@ use codex_features::FeaturesToml; use codex_login::default_client::originator; use codex_otel::sanitize_metric_tag_value; use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::models::PermissionProfile; use std::collections::BTreeMap; use std::collections::HashMap; use std::path::Path; @@ -145,16 +145,17 @@ pub fn elevated_setup_failure_metric_name(_err: &anyhow::Error) -> &'static str #[cfg(target_os = "windows")] pub fn run_elevated_setup( - policy: &SandboxPolicy, - policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, command_cwd: &Path, env_map: &HashMap, codex_home: &Path, ) -> anyhow::Result<()> { let permissions = - codex_windows_sandbox::ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd( - policy, policy_cwd, - ); + codex_windows_sandbox::ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_cwd( + permission_profile, + permission_profile_cwd, + )?; codex_windows_sandbox::run_elevated_setup( codex_windows_sandbox::SandboxSetupRequest { permissions: &permissions, @@ -169,8 +170,8 @@ pub fn run_elevated_setup( #[cfg(not(target_os = "windows"))] pub fn run_elevated_setup( - _policy: &SandboxPolicy, - _policy_cwd: &Path, + _permission_profile: &PermissionProfile, + _permission_profile_cwd: &Path, _command_cwd: &Path, _env_map: &HashMap, _codex_home: &Path, @@ -180,15 +181,15 @@ pub fn run_elevated_setup( #[cfg(target_os = "windows")] pub fn run_legacy_setup_preflight( - policy: &SandboxPolicy, - policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, command_cwd: &Path, env_map: &HashMap, codex_home: &Path, ) -> anyhow::Result<()> { codex_windows_sandbox::run_windows_sandbox_legacy_preflight( - policy, - policy_cwd, + permission_profile, + permission_profile_cwd, codex_home, command_cwd, env_map, @@ -197,16 +198,16 @@ pub fn run_legacy_setup_preflight( #[cfg(target_os = "windows")] pub fn run_setup_refresh_with_extra_read_roots( - policy: &SandboxPolicy, - policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, command_cwd: &Path, env_map: &HashMap, codex_home: &Path, extra_read_roots: Vec, ) -> anyhow::Result<()> { codex_windows_sandbox::run_setup_refresh_with_extra_read_roots( - policy, - policy_cwd, + permission_profile, + permission_profile_cwd, command_cwd, env_map, codex_home, @@ -217,8 +218,8 @@ pub fn run_setup_refresh_with_extra_read_roots( #[cfg(not(target_os = "windows"))] pub fn run_legacy_setup_preflight( - _policy: &SandboxPolicy, - _policy_cwd: &Path, + _permission_profile: &PermissionProfile, + _permission_profile_cwd: &Path, _command_cwd: &Path, _env_map: &HashMap, _codex_home: &Path, @@ -228,8 +229,8 @@ pub fn run_legacy_setup_preflight( #[cfg(not(target_os = "windows"))] pub fn run_setup_refresh_with_extra_read_roots( - _policy: &SandboxPolicy, - _policy_cwd: &Path, + _permission_profile: &PermissionProfile, + _permission_profile_cwd: &Path, _command_cwd: &Path, _env_map: &HashMap, _codex_home: &Path, @@ -247,8 +248,8 @@ pub enum WindowsSandboxSetupMode { #[derive(Debug, Clone)] pub struct WindowsSandboxSetupRequest { pub mode: WindowsSandboxSetupMode, - pub policy: SandboxPolicy, - pub policy_cwd: PathBuf, + pub permission_profile: PermissionProfile, + pub permission_profile_cwd: PathBuf, pub command_cwd: PathBuf, pub env_map: HashMap, pub codex_home: PathBuf, @@ -285,8 +286,8 @@ async fn run_windows_sandbox_setup_and_persist( request: WindowsSandboxSetupRequest, ) -> anyhow::Result<()> { let mode = request.mode; - let policy = request.policy; - let policy_cwd = request.policy_cwd; + let permission_profile = request.permission_profile; + let permission_profile_cwd = request.permission_profile_cwd; let command_cwd = request.command_cwd; let env_map = request.env_map; let codex_home = request.codex_home; @@ -297,8 +298,8 @@ async fn run_windows_sandbox_setup_and_persist( WindowsSandboxSetupMode::Elevated => { if !sandbox_setup_is_complete(setup_codex_home.as_path()) { run_elevated_setup( - &policy, - policy_cwd.as_path(), + &permission_profile, + permission_profile_cwd.as_path(), command_cwd.as_path(), &env_map, setup_codex_home.as_path(), @@ -307,8 +308,8 @@ async fn run_windows_sandbox_setup_and_persist( } WindowsSandboxSetupMode::Unelevated => { run_legacy_setup_preflight( - &policy, - policy_cwd.as_path(), + &permission_profile, + permission_profile_cwd.as_path(), command_cwd.as_path(), &env_map, setup_codex_home.as_path(), diff --git a/codex-rs/core/src/windows_sandbox_read_grants.rs b/codex-rs/core/src/windows_sandbox_read_grants.rs index b0891ec6a6..a9561372ce 100644 --- a/codex-rs/core/src/windows_sandbox_read_grants.rs +++ b/codex-rs/core/src/windows_sandbox_read_grants.rs @@ -1,13 +1,13 @@ use crate::windows_sandbox::run_setup_refresh_with_extra_read_roots; use anyhow::Result; -use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::models::PermissionProfile; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; pub fn grant_read_root_non_elevated( - policy: &SandboxPolicy, - policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, command_cwd: &Path, env_map: &HashMap, codex_home: &Path, @@ -25,8 +25,8 @@ pub fn grant_read_root_non_elevated( let canonical_root = dunce::canonicalize(read_root)?; run_setup_refresh_with_extra_read_roots( - policy, - policy_cwd, + permission_profile, + permission_profile_cwd, command_cwd, env_map, codex_home, diff --git a/codex-rs/core/src/windows_sandbox_read_grants_tests.rs b/codex-rs/core/src/windows_sandbox_read_grants_tests.rs index bffa840ede..93cbcddfe3 100644 --- a/codex-rs/core/src/windows_sandbox_read_grants_tests.rs +++ b/codex-rs/core/src/windows_sandbox_read_grants_tests.rs @@ -1,18 +1,18 @@ use super::grant_read_root_non_elevated; -use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::models::PermissionProfile; use std::collections::HashMap; use std::path::Path; use tempfile::TempDir; -fn policy() -> SandboxPolicy { - SandboxPolicy::new_workspace_write_policy() +fn permission_profile() -> PermissionProfile { + PermissionProfile::workspace_write() } #[test] fn rejects_relative_path() { let tmp = TempDir::new().expect("tempdir"); let err = grant_read_root_non_elevated( - &policy(), + &permission_profile(), tmp.path(), tmp.path(), &HashMap::new(), @@ -28,7 +28,7 @@ fn rejects_missing_path() { let tmp = TempDir::new().expect("tempdir"); let missing = tmp.path().join("does-not-exist"); let err = grant_read_root_non_elevated( - &policy(), + &permission_profile(), tmp.path(), tmp.path(), &HashMap::new(), @@ -45,7 +45,7 @@ fn rejects_file_path() { let file_path = tmp.path().join("file.txt"); std::fs::write(&file_path, "hello").expect("write file"); let err = grant_read_root_non_elevated( - &policy(), + &permission_profile(), tmp.path(), tmp.path(), &HashMap::new(), diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 2f2875867b..7f7873e30b 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -27,12 +27,15 @@ const SERVER_NAME: &str = "codex-apps-test"; const SERVER_VERSION: &str = "1.0.0"; const SEARCHABLE_TOOL_COUNT: usize = 100; const CALENDAR_CREATE_EVENT_TOOL_NAME: &str = "calendar_create_event"; +const CALENDAR_APP_ONLY_TOOL_NAME: &str = "calendar_app_only_action"; pub const CALENDAR_EXTRACT_TEXT_TOOL_NAME: &str = "calendar_extract_text"; const CALENDAR_LIST_EVENTS_TOOL_NAME: &str = "calendar_list_events"; pub const DIRECT_CALENDAR_CREATE_EVENT_TOOL: &str = "mcp__codex_apps__calendar__create_event"; +pub const DIRECT_CALENDAR_APP_ONLY_TOOL: &str = "mcp__codex_apps__calendar__app_only_action"; pub const DIRECT_CALENDAR_LIST_EVENTS_TOOL: &str = "mcp__codex_apps__calendar__list_events"; pub const DIRECT_CALENDAR_EXTRACT_TEXT_TOOL: &str = "mcp__codex_apps__calendar__extract_text"; pub const SEARCH_CALENDAR_NAMESPACE: &str = "mcp__codex_apps__calendar"; +pub const SEARCH_CALENDAR_APP_ONLY_TOOL: &str = "_app_only_action"; pub const SEARCH_CALENDAR_CREATE_TOOL: &str = "_create_event"; pub const SEARCH_CALENDAR_EXTRACT_TEXT_TOOL: &str = "_extract_text"; pub const SEARCH_CALENDAR_LIST_TOOL: &str = "_list_events"; @@ -49,6 +52,12 @@ pub struct AppsTestServer { pub chatgpt_base_url: String, } +#[derive(Clone, Copy)] +pub enum AppsTestToolLoading { + Direct, + Searchable, +} + impl AppsTestServer { pub async fn mount(server: &MockServer) -> Result { Self::mount_with_connector_name(server, CONNECTOR_NAME).await @@ -62,6 +71,7 @@ impl AppsTestServer { CONNECTOR_NAME.to_string(), CONNECTOR_DESCRIPTION.to_string(), /*searchable*/ true, + /*include_app_only_tool*/ false, ) .await; Ok(Self { @@ -80,6 +90,26 @@ impl AppsTestServer { connector_name.to_string(), CONNECTOR_DESCRIPTION.to_string(), /*searchable*/ false, + /*include_app_only_tool*/ false, + ) + .await; + Ok(Self { + chatgpt_base_url: server.uri(), + }) + } + + pub async fn mount_with_app_only_tool( + server: &MockServer, + tool_loading: AppsTestToolLoading, + ) -> Result { + mount_oauth_metadata(server).await; + mount_connectors_directory(server).await; + mount_streamable_http_json_rpc( + server, + CONNECTOR_NAME.to_string(), + CONNECTOR_DESCRIPTION.to_string(), + matches!(tool_loading, AppsTestToolLoading::Searchable), + /*include_app_only_tool*/ true, ) .await; Ok(Self { @@ -136,7 +166,7 @@ fn apps_tool_call_id(body: &Value) -> Option<&str> { .as_str() } -async fn recorded_apps_tool_calls(server: &MockServer) -> Vec { +pub async fn recorded_apps_tool_calls(server: &MockServer) -> Vec { server .received_requests() .await @@ -233,6 +263,7 @@ async fn mount_streamable_http_json_rpc( connector_name: String, connector_description: String, searchable: bool, + include_app_only_tool: bool, ) { Mock::given(method("POST")) .and(path_regex("^/api/codex/apps/?$")) @@ -240,6 +271,7 @@ async fn mount_streamable_http_json_rpc( connector_name, connector_description, searchable, + include_app_only_tool, }) .mount(server) .await; @@ -249,6 +281,7 @@ struct CodexAppsJsonRpcResponder { connector_name: String, connector_description: String, searchable: bool, + include_app_only_tool: bool, } impl Respond for CodexAppsJsonRpcResponder { @@ -419,6 +452,29 @@ impl Respond for CodexAppsJsonRpcResponder { })); } } + if self.include_app_only_tool + && let Some(tools) = response + .pointer_mut("/result/tools") + .and_then(Value::as_array_mut) + { + tools.push(json!({ + "name": CALENDAR_APP_ONLY_TOOL_NAME, + "description": "Open a calendar app-only action.", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "_meta": { + "connector_id": CONNECTOR_ID, + "connector_name": self.connector_name.clone(), + "connector_description": self.connector_description.clone(), + "ui": { + "visibility": ["app"] + } + } + })); + } ResponseTemplate::new(200).set_body_json(response) } "tools/call" => { diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index 6fe697d2b1..c405e35995 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -95,6 +95,16 @@ async fn websocket_first_turn_uses_startup_prewarm_and_create() -> Result<()> { let turn = connection.get(1).expect("missing turn request").body_json(); assert_eq!(warmup["type"].as_str(), Some("response.create")); assert_eq!(warmup["generate"].as_bool(), Some(false)); + let warmup_metadata: Value = serde_json::from_str( + warmup["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .expect("warmup turn metadata"), + )?; + assert_eq!(warmup_metadata["request_kind"].as_str(), Some("prewarm")); + assert_eq!( + warmup_metadata["window_id"].as_str(), + warmup["client_metadata"]["x-codex-window-id"].as_str() + ); assert!( turn["tools"] .as_array() @@ -102,6 +112,12 @@ async fn websocket_first_turn_uses_startup_prewarm_and_create() -> Result<()> { "expected request tools to be populated" ); assert_eq!(turn["type"].as_str(), Some("response.create")); + let turn_metadata: Value = serde_json::from_str( + turn["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .expect("turn metadata"), + )?; + assert_eq!(turn_metadata["request_kind"].as_str(), Some("turn")); server.shutdown().await; Ok(()) diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index e16a4a14a2..2a5f8fc43f 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -46,6 +46,8 @@ use serde_json::Value; use serde_json::json; use std::env; use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -2528,6 +2530,166 @@ async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() -> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[cfg(unix)] +async fn env_zsh_script_spawned_by_python_can_request_escalation_under_zsh_fork() -> Result<()> { + skip_if_no_network!(Ok(())); + + let Some(runtime) = zsh_fork_runtime("zsh-fork env zsh nested escalation test")? else { + return Ok(()); + }; + + let approval_policy = AskForApproval::OnRequest; + let permission_profile = restrictive_workspace_write_profile(); + let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?; + let outside_path = outside_dir.path().join("zsh-fork-env-zsh-escalated.txt"); + let outside_path_arg = shlex::try_join([outside_path.to_string_lossy().as_ref()])?; + let rules = r#"prefix_rule(pattern=["touch"], decision="prompt")"#.to_string(); + + let server = start_mock_server().await; + let outside_path_for_hook = outside_path.clone(); + let test = build_zsh_fork_test( + &server, + runtime, + approval_policy, + permission_profile.clone(), + move |home| { + let _ = fs::remove_file(&outside_path_for_hook); + let rules_dir = home.join("rules"); + fs::create_dir_all(&rules_dir).unwrap(); + fs::write(rules_dir.join("default.rules"), &rules).unwrap(); + }, + ) + .await?; + + let script_path = test.cwd.path().join("runs-under-env-zsh"); + fs::write( + &script_path, + format!( + "#!/usr/bin/env zsh\ntouch {outside_path_arg}\nprint -r -- nested-env-zsh-complete\n" + ), + )?; + let mut script_permissions = fs::metadata(&script_path)?.permissions(); + script_permissions.set_mode(0o755); + fs::set_permissions(&script_path, script_permissions)?; + + let script_literal = serde_json::to_string(script_path.to_string_lossy().as_ref())?; + let python_script = format!( + "import subprocess; subprocess.run([{script_literal}], check=True, close_fds=False)" + ); + let command = shlex::try_join(["python3", "-c", python_script.as_str()])?; + + let call_id = "zsh-fork-env-zsh-nested-escalation"; + let event = shell_event( + call_id, + &command, + /*timeout_ms*/ 30_000, + SandboxPermissions::UseDefault, + )?; + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-zsh-fork-env-zsh-1"), + event, + ev_completed("resp-zsh-fork-env-zsh-1"), + ]), + ) + .await; + let results = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-zsh-fork-env-zsh-1", "done"), + ev_completed("resp-zsh-fork-env-zsh-2"), + ]), + ) + .await; + + let session_model = test.session_configured.model.clone(); + let (sandbox_policy, permission_profile) = + turn_permission_fields(permission_profile, test.cwd.path()); + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "run nested env zsh script through python".into(), + text_elements: Vec::new(), + }], + environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd.path().to_path_buf()), + approval_policy: Some(approval_policy), + approvals_reviewer: Some(ApprovalsReviewer::User), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + + let approval_event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }, + Duration::from_secs(10), + ) + .await; + let EventMsg::ExecApprovalRequest(approval) = approval_event else { + panic!("expected nested zsh script to request approval before completion"); + }; + assert!( + approval.command.iter().any(|arg| arg.ends_with("/touch")) + && approval + .command + .iter() + .any(|arg| arg == outside_path.to_string_lossy().as_ref()), + "expected approval for nested touch command, got: {:?}", + approval.command + ); + + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + + wait_for_completion(&test).await; + + let result = parse_result(&results.single_request().function_call_output(call_id)); + assert_eq!( + result.exit_code.unwrap_or(0), + 0, + "nested env zsh script should complete successfully: {}", + result.stdout + ); + assert!( + result.stdout.contains("nested-env-zsh-complete"), + "nested script did not report completion: {}", + result.stdout + ); + assert!( + outside_path.exists(), + "approved nested touch should create the out-of-workspace file" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[cfg(unix)] async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> { diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index fb9e7fb420..cbf6993bc9 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -18,6 +18,10 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::AppsTestToolLoading; +use core_test_support::apps_test_server::DIRECT_CALENDAR_APP_ONLY_TOOL; +use core_test_support::apps_test_server::recorded_apps_tool_calls; +use core_test_support::apps_test_server::search_capable_apps_builder; use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::ResponseMock; @@ -495,6 +499,92 @@ if (!tool) { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn app_only_tools_are_not_visible_or_runnable_by_code_mode_model() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let apps_server = + AppsTestServer::mount_with_app_only_tool(&server, AppsTestToolLoading::Searchable).await?; + let code = format!( + r#" +const visibleTool = ALL_TOOLS.find(({{ name }}) => name === {visible_tool_name:?}); +const tool = ALL_TOOLS.find(({{ name }}) => name === {tool_name:?}); +let error = null; +try {{ + await tools[{tool_name:?}]({{}}); +}} catch (caught) {{ + error = String(caught); +}} +text(JSON.stringify({{ + visibleListed: visibleTool !== undefined, + listed: tool !== undefined, + callable: typeof tools[{tool_name:?}] === "function", + error, +}})); +"#, + visible_tool_name = "mcp__codex_apps__calendar_timezone_option_99", + tool_name = DIRECT_CALENDAR_APP_ONLY_TOOL, + ); + + responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_custom_tool_call("call-1", "exec", &code), + ev_completed("resp-1"), + ]), + ) + .await; + let second_mock = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone()) + .with_config(|config| { + config + .features + .enable(Feature::CodeMode) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::CodeModeOnly) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + test.submit_turn("try to call the app-only calendar tool through exec") + .await?; + + let req = second_mock.single_request(); + let (output, success) = custom_tool_output_body_and_success(&req, "call-1"); + assert_ne!( + success, + Some(false), + "code mode visibility check should complete successfully: {output}" + ); + let parsed: Value = serde_json::from_str(&output)?; + assert_eq!(parsed["visibleListed"], true); + assert_eq!(parsed["listed"], false); + assert_eq!(parsed["callable"], false); + assert!( + parsed["error"] + .as_str() + .is_some_and(|error| error.contains("is not a function")), + "app-only code mode call should fail before MCP dispatch: {parsed:?}" + ); + assert!( + recorded_apps_tool_calls(&server).await.is_empty(), + "app-only code mode call should not reach the MCP server" + ); + + Ok(()) +} + #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_only_can_call_nested_tools() -> Result<()> { @@ -2861,7 +2951,7 @@ text(JSON.stringify(tool)); parsed, serde_json::json!({ "name": "view_image", - "description": "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", + "description": "View a local image file from the filesystem when visual inspection is needed. Use this for images already available on disk.\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", }) ); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index be6d30c6fa..0e2c02de91 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -559,10 +559,8 @@ async fn summarize_context_three_requests_and_instructions() { RolloutItem::TurnContext(_) => { regular_turn_context_count += 1; } - RolloutItem::Compacted(ci) => { - if ci.message == expected_summary_message { - saw_compacted_summary = true; - } + RolloutItem::Compacted(ci) if ci.message == expected_summary_message => { + saw_compacted_summary = true; } _ => {} } @@ -2790,11 +2788,58 @@ async fn manual_compact_twice_preserves_latest_user_messages() { contains_user_text(&requests[1], first_user_message), "first compact request should include history before compaction" ); + let compact_metadata: Value = serde_json::from_str( + &requests[1] + .header("x-codex-turn-metadata") + .expect("local compact request should include turn metadata"), + ) + .expect("local compact turn metadata should be valid json"); + assert_eq!( + compact_metadata["request_kind"].as_str(), + Some("compaction") + ); + assert_eq!( + compact_metadata["window_id"].as_str(), + requests[1].header("x-codex-window-id").as_deref() + ); + assert_eq!( + compact_metadata["compaction"], + json!({ + "trigger": "manual", + "reason": "user_requested", + "implementation": "responses", + "phase": "standalone_turn", + "strategy": "memento", + }) + ); assert!( contains_user_text(&requests[2], second_user_message), "second turn request missing second user message" ); + let next_turn_metadata: Value = serde_json::from_str( + &requests[2] + .header("x-codex-turn-metadata") + .expect("next regular request should include turn metadata"), + ) + .expect("next regular turn metadata should be valid json"); + assert_eq!( + next_turn_metadata["request_kind"].as_str(), + Some("turn"), + "regular requests after compaction should remain turn requests" + ); + assert_eq!( + next_turn_metadata["window_id"].as_str(), + requests[2].header("x-codex-window-id").as_deref() + ); + assert_ne!( + compact_metadata["window_id"], next_turn_metadata["window_id"], + "the next request should use the new compacted context window" + ); + assert!( + next_turn_metadata.get("compaction").is_none(), + "regular requests after compaction should not be marked as compact requests" + ); assert!( contains_user_text(&requests[2], first_user_message), "second turn request should include the compacted user history" diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index d8779bfb05..1c5b80f35f 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -110,7 +110,7 @@ fn canonical_json(value: &Value) -> Value { match value { Value::Object(map) => { let mut entries = map.iter().collect::>(); - entries.sort_by(|(left_key, _), (right_key, _)| left_key.cmp(right_key)); + entries.sort_by_key(|(left_key, _)| *left_key); Value::Object( entries .into_iter() @@ -368,6 +368,36 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { compact_request.header("thread-id").as_deref(), Some(thread_id.as_str()) ); + let compact_metadata: Value = serde_json::from_str( + &compact_request + .header("x-codex-turn-metadata") + .expect("remote compact request should include turn metadata"), + ) + .expect("remote compact turn metadata should be valid json"); + assert!( + compact_metadata["turn_id"] + .as_str() + .is_some_and(|id| !id.is_empty()), + "remote compact turn metadata should include its turn id" + ); + assert_eq!( + compact_metadata["request_kind"].as_str(), + Some("compaction") + ); + assert_eq!( + compact_metadata["window_id"].as_str(), + compact_request.header("x-codex-window-id").as_deref() + ); + assert_eq!( + compact_metadata["compaction"], + json!({ + "trigger": "manual", + "reason": "user_requested", + "implementation": "responses_compact", + "phase": "standalone_turn", + "strategy": "memento", + }) + ); let compact_body = compact_request.body_json(); assert_eq!( compact_body.get("model").and_then(|v| v.as_str()), @@ -375,6 +405,16 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { ); let response_requests = responses_mock.requests(); let first_response_request = response_requests.first().expect("initial request missing"); + let first_response_metadata: Value = serde_json::from_str( + &first_response_request + .header("x-codex-turn-metadata") + .expect("initial request should include turn metadata"), + ) + .expect("initial turn metadata should be valid json"); + assert_ne!( + first_response_metadata["turn_id"], compact_metadata["turn_id"], + "manual compaction should use its own turn id" + ); assert_eq!( compact_body["tools"], first_response_request.body_json()["tools"], @@ -407,6 +447,33 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { let response_requests = responses_mock.requests(); let follow_up_request = response_requests.last().expect("follow-up request missing"); + let follow_up_metadata: Value = serde_json::from_str( + &follow_up_request + .header("x-codex-turn-metadata") + .expect("follow-up request should include turn metadata"), + ) + .expect("follow-up turn metadata should be valid json"); + assert_eq!( + follow_up_metadata["request_kind"].as_str(), + Some("turn"), + "regular requests after compaction should remain turn requests" + ); + assert!( + follow_up_metadata.get("compaction").is_none(), + "regular requests after compaction should not be marked as compact requests" + ); + assert_ne!( + follow_up_metadata["turn_id"], compact_metadata["turn_id"], + "the following user turn should not reuse a manual compact turn id" + ); + assert_eq!( + follow_up_metadata["window_id"].as_str(), + follow_up_request.header("x-codex-window-id").as_deref() + ); + assert_ne!( + follow_up_metadata["window_id"], compact_metadata["window_id"], + "the following user turn should use the new compacted context window" + ); let follow_up_body = follow_up_request.body_json().to_string(); assert!( follow_up_body.contains("\"type\":\"compaction\""), @@ -797,6 +864,30 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( "expected compact request to advertise the remote_compaction_v2 beta feature" ); assert_eq!(compact_request.path(), "/v1/responses"); + let compact_metadata: Value = serde_json::from_str( + &compact_request + .header("x-codex-turn-metadata") + .expect("v2 compact request should include turn metadata"), + ) + .expect("v2 compact turn metadata should be valid json"); + assert_eq!( + compact_metadata["request_kind"].as_str(), + Some("compaction") + ); + assert_eq!( + compact_metadata["window_id"].as_str(), + compact_request.header("x-codex-window-id").as_deref() + ); + assert_eq!( + compact_metadata["compaction"], + json!({ + "trigger": "manual", + "reason": "user_requested", + "implementation": "responses_compaction_v2", + "phase": "standalone_turn", + "strategy": "memento", + }) + ); let compact_body = compact_request.body_json().to_string(); assert!( compact_body.contains("\"type\":\"compaction_trigger\""), @@ -1139,7 +1230,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { let session_id = harness.test().session_configured.session_id.to_string(); let thread_id = harness.test().session_configured.thread_id.to_string(); - mount_sse_once( + let initial_request = mount_sse_once( harness.server(), sse(vec![ responses::ev_shell_command_call("m1", "echo 'hi'"), @@ -1195,7 +1286,63 @@ async fn remote_compact_runs_automatically() -> Result<()> { compact_mock.single_request().header("thread-id").as_deref(), Some(thread_id.as_str()) ); + let compact_metadata: Value = serde_json::from_str( + &compact_mock + .single_request() + .header("x-codex-turn-metadata") + .expect("auto remote compact request should include turn metadata"), + ) + .expect("auto remote compact turn metadata should be valid json"); + assert_eq!( + compact_metadata["request_kind"].as_str(), + Some("compaction") + ); + assert_eq!( + compact_metadata["compaction"], + json!({ + "trigger": "auto", + "reason": "context_limit", + "implementation": "responses_compact", + "phase": "mid_turn", + "strategy": "memento", + }) + ); + let initial_metadata: Value = serde_json::from_str( + &initial_request + .single_request() + .header("x-codex-turn-metadata") + .expect("initial request should include turn metadata"), + ) + .expect("initial turn metadata should be valid json"); + assert_eq!( + initial_metadata["turn_id"], compact_metadata["turn_id"], + "automatic mid-turn compaction should keep the current turn id" + ); + assert_eq!( + initial_metadata["window_id"], compact_metadata["window_id"], + "automatic mid-turn compaction summarizes the current context window" + ); let follow_up_request = responses_mock.single_request(); + let follow_up_metadata: Value = serde_json::from_str( + &follow_up_request + .header("x-codex-turn-metadata") + .expect("post-compaction continuation should include turn metadata"), + ) + .expect("post-compaction turn metadata should be valid json"); + assert_eq!( + follow_up_metadata["request_kind"].as_str(), + Some("turn"), + "post-compaction continuation should be a regular request" + ); + assert!(follow_up_metadata.get("compaction").is_none()); + assert_eq!( + follow_up_metadata["turn_id"], compact_metadata["turn_id"], + "automatic mid-turn continuation should keep the current turn id" + ); + assert_ne!( + follow_up_metadata["window_id"], compact_metadata["window_id"], + "post-compaction continuation should use the next context window" + ); let follow_up_body = follow_up_request.body_json().to_string(); assert!(follow_up_body.contains("REMOTE_COMPACTED_SUMMARY")); diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 8979b2cced..22678fc46f 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -1047,7 +1047,7 @@ fn canonical_json(value: &Value) -> Value { match value { Value::Object(map) => { let mut entries = map.iter().collect::>(); - entries.sort_by(|(left_key, _), (right_key, _)| left_key.cmp(right_key)); + entries.sort_by_key(|(left_key, _)| *left_key); Value::Object( entries .into_iter() diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index ed2f1a8e1b..92284b2a1e 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -638,10 +638,8 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { fn normalize_line_endings(value: &mut Value) { match value { - Value::String(text) => { - if text.contains('\r') { - *text = text.replace("\r\n", "\n").replace('\r', "\n"); - } + Value::String(text) if text.contains('\r') => { + *text = text.replace("\r\n", "\n").replace('\r', "\n"); } Value::Array(items) => { for item in items { diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs new file mode 100644 index 0000000000..df38fb4353 --- /dev/null +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -0,0 +1,170 @@ +#![cfg(not(target_os = "windows"))] + +use anyhow::Result; +use codex_core::config::Constrained; +use codex_core::sandboxing::SandboxPermissions; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::user_input::UserInput; +use core_test_support::fs_wait; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_sandbox; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::time::Duration; +use tempfile::TempDir; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn guardian_review_session_does_not_inherit_legacy_notify() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + let notify_dir = TempDir::new()?; + let notify_script = notify_dir.path().join("notify.sh"); + fs::write( + ¬ify_script, + r#"#!/bin/bash +set -e +payload_path="$(dirname "${0}")/notify.jsonl" +printf '%s\n' "${@: -1}" >> "${payload_path}""#, + )?; + fs::set_permissions(¬ify_script, fs::Permissions::from_mode(0o755))?; + let notify_file = notify_dir.path().join("notify.jsonl"); + let notify_script_str = notify_script.to_str().unwrap().to_string(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.notify = Some(vec![notify_script_str]); + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config + .set_legacy_sandbox_policy(sandbox_policy_for_config) + .expect("set sandbox policy"); + }); + let test = builder.build(&server).await?; + + let output_file = test.cwd.path().join("guardian-review-notify.txt"); + let command = format!("printf guardian-approved > {}", output_file.display()); + let tool_args = json!({ + "cmd": command, + "yield_time_ms": 1_000_u64, + "sandbox_permissions": SandboxPermissions::RequireEscalated, + "justification": "Exercise Guardian approval routing.", + }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-parent-tool"), + ev_function_call( + "exec-call", + "exec_command", + &serde_json::to_string(&tool_args)?, + ), + ev_completed("resp-parent-tool"), + ]), + sse(vec![ + ev_response_created("resp-guardian-review"), + ev_assistant_message( + "msg-guardian-review", + &json!({ + "risk_level": "low", + "user_authorization": "high", + "outcome": "allow", + "rationale": "The command writes a marker file in the workspace.", + }) + .to_string(), + ), + ev_completed("resp-guardian-review"), + ]), + sse(vec![ + ev_response_created("resp-parent-done"), + ev_assistant_message("msg-parent-done", "done"), + ev_completed("resp-parent-done"), + ]), + ], + ) + .await; + + test.codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "run a command that requires Guardian review".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd.path().to_path_buf()), + approval_policy: Some(approval_policy), + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + sandbox_policy: Some(sandbox_policy), + ..Default::default() + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let guardian_request = responses + .requests() + .into_iter() + .find(|request| request.body_contains_text("Exercise Guardian approval routing.")) + .expect("expected Guardian review request"); + assert!(guardian_request.body_contains_text(&command)); + + fs_wait::wait_for_path_exists(¬ify_file, Duration::from_secs(5)).await?; + tokio::time::sleep(Duration::from_millis(100)).await; + let notify_payload_raw = tokio::fs::read_to_string(¬ify_file).await?; + let payloads: Vec = notify_payload_raw + .lines() + .map(serde_json::from_str::) + .collect::>()?; + + assert_eq!( + payloads.len(), + 1, + "unexpected notify payloads: {payloads:?}" + ); + assert_eq!( + payloads[0]["input-messages"], + json!(["run a command that requires Guardian review"]) + ); + assert_eq!(payloads[0]["last-assistant-message"], json!("done")); + assert!( + !notify_payload_raw.contains( + "The following is the Codex agent history whose request action you are assessing." + ), + "Guardian review transcript leaked into legacy notify payload: {notify_payload_raw}" + ); + assert_eq!(fs::read_to_string(&output_file)?, "guardian-approved"); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 67a79d603f..87eb03079e 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -257,16 +257,10 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> id: None, role: "user".to_string(), content: vec![ - ContentItem::InputText { - text: codex_protocol::models::image_open_tag_text(), - }, ContentItem::InputImage { image_url, detail: Some(DEFAULT_IMAGE_DETAIL), }, - ContentItem::InputText { - text: codex_protocol::models::image_close_tag_text(), - }, ContentItem::InputText { text: "dropped image".to_string(), }, diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index f47de672bd..f4ab6c1d6d 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -50,6 +50,8 @@ mod deprecation_notice; mod exec; mod exec_policy; mod fork_thread; +#[cfg(not(target_os = "windows"))] +mod guardian_review; mod hierarchical_agents; #[cfg(not(target_os = "windows"))] mod hooks; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index fd237a8d5c..3c4dd40b32 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -555,19 +555,6 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< .any(|text| text == "image content omitted because you do not support image input"), "second request should include the image-omitted placeholder text" ); - assert!( - second_user_texts - .iter() - .any(|text| text == &codex_protocol::models::image_open_tag_text()), - "second request should preserve the image open tag text" - ); - assert!( - second_user_texts - .iter() - .any(|text| text == &codex_protocol::models::image_close_tag_text()), - "second request should preserve the image close tag text" - ); - Ok(()) } diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index c8eec9bc8e..96b89caec8 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -152,6 +152,7 @@ async fn steer_user_input(codex: &CodexThread, text: &str) { }], /*additional_context*/ Default::default(), /*expected_turn_id*/ None, + /*client_user_message_id*/ None, /*responsesapi_client_metadata*/ None, ) .await diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 7c9183f8d9..9cee89d761 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -84,6 +84,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R let user_event = RolloutLine { timestamp: TEST_TIMESTAMP.to_string(), item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "hello".to_string(), images: None, local_images: Vec::new(), diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 836761961a..343a49ec7f 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -46,10 +46,7 @@ fn text_user_input_parts(texts: Vec) -> serde_json::Value { } fn assert_default_env_context(text: &str, cwd: &str) { - assert!( - text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG), - "expected environment context fragment: {text}" - ); + assert_env_context_fragment(text); assert!( text.contains(&format!("{cwd}")), "expected cwd in environment context: {text}" @@ -58,6 +55,13 @@ fn assert_default_env_context(text: &str, cwd: &str) { text.contains(&format!("{}", default_user_shell().name())), "expected shell in environment context: {text}" ); +} + +fn assert_env_context_fragment(text: &str) { + assert!( + text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG), + "expected environment context fragment: {text}" + ); assert!( text.contains("") && text.contains(""), "expected current_date in environment context: {text}" @@ -502,8 +506,24 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an expected_permissions_msg_2, expected_permissions_msg, "expected updated permissions message after override" ); + let expected_env_msg_2 = body2["input"][body1_input.len() + 1].clone(); + assert_eq!(expected_env_msg_2["role"].as_str(), Some("user")); + let env_text = expected_env_msg_2["content"][0]["text"] + .as_str() + .expect("environment context text"); + assert_env_context_fragment(env_text); + assert!( + env_text.contains("") + && env_text.contains("") + && env_text.contains(&format!( + "{}", + writable.abs().display() + )), + "expected workspace-write filesystem profile in environment context: {env_text}" + ); let mut expected_body2 = body1_input.to_vec(); expected_body2.push(expected_permissions_msg_2); + expected_body2.push(expected_env_msg_2); expected_body2.push(expected_user_message_2); assert_eq!(body2["input"], serde_json::Value::Array(expected_body2)); @@ -1086,12 +1106,25 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu }), "expected model switch section after model override: {expected_settings_update_msg:?}" ); + let expected_env_update_msg = body2["input"][body1_input.len() + 1].clone(); + assert_eq!(expected_env_update_msg["role"].as_str(), Some("user")); + let expected_env_update_text = expected_env_update_msg["content"][0]["text"] + .as_str() + .expect("environment context text"); + assert_env_context_fragment(expected_env_update_text); + assert!( + expected_env_update_text.contains( + "", + ), + "expected disabled filesystem profile in environment context: {expected_env_update_text}" + ); let expected_user_message_2 = text_user_input("hello 2".to_string()); let expected_input_2 = serde_json::Value::Array(vec![ expected_permissions_msg, expected_contextual_user_msg_1, expected_user_message_1, expected_settings_update_msg, + expected_env_update_msg, expected_user_message_2, ]); assert_eq!(body2["input"], expected_input_2); diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 05428799ad..b99333e66c 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -1835,15 +1835,14 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge "head detail ".repeat(120), "tail detail ".repeat(170), ); - let mut user_turns = (1..=7) + let user_turns = (1..=7) .map(|index| { format!( "short-turn-{index}-start {} short-turn-{index}-end", "detail ".repeat(86) ) }) - .collect::>(); - user_turns.push(latest_long_user_turn.clone()); + .chain([latest_long_user_turn.clone()]); let mut builder = test_codex().with_config({ let realtime_base_url = realtime_server.uri().to_string(); @@ -1858,7 +1857,6 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge // end-to-end startup-context test without paying for a model turn per // fixture entry in platform CI. let history = user_turns - .into_iter() .enumerate() .flat_map(|(index, user_turn)| { let turn_number = index + 1; diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 51242ede2a..9cb2980725 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -28,6 +28,7 @@ fn resume_history( let turn_ctx = TurnContextItem { turn_id: Some(turn_id.clone()), cwd: config.cwd.to_path_buf(), + workspace_roots: None, current_date: None, timezone: None, approval_policy: config.permissions.approval_policy.value(), @@ -56,6 +57,7 @@ fn resume_history( collaboration_mode_kind: ModeKind::Default, })), RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "seed".to_string(), images: None, local_images: vec![], diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index e9ffeb62ef..c548ded95c 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -17,16 +17,20 @@ use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::AppsTestToolLoading; use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI; use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_RESOURCE_URI; use core_test_support::apps_test_server::DIRECT_CALENDAR_CREATE_EVENT_TOOL as CALENDAR_CREATE_TOOL; use core_test_support::apps_test_server::DIRECT_CALENDAR_LIST_EVENTS_TOOL as CALENDAR_LIST_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_APP_ONLY_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_LIST_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; +use core_test_support::apps_test_server::apps_enabled_builder; use core_test_support::apps_test_server::configure_search_capable_apps; use core_test_support::apps_test_server::configure_search_capable_model; use core_test_support::apps_test_server::recorded_apps_tool_call_by_call_id; +use core_test_support::apps_test_server::recorded_apps_tool_calls; use core_test_support::apps_test_server::search_capable_apps_builder as configured_builder; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; @@ -216,6 +220,80 @@ async fn always_defer_feature_hides_small_app_tool_sets() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn app_only_tools_are_not_visible_or_runnable_by_direct_model_calls() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = + AppsTestServer::mount_with_app_only_tool(&server, AppsTestToolLoading::Direct).await?; + let call_id = "app-only-direct-call"; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call_with_namespace( + call_id, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_APP_ONLY_TOOL, + "{}", + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url.clone()); + let test = builder.build(&server).await?; + test.submit_turn_with_approval_and_permission_profile( + "Try to call the app-only calendar tool.", + AskForApproval::Never, + PermissionProfile::Disabled, + ) + .await?; + + let requests = mock.requests(); + assert!( + namespace_child_tool( + &requests[0].body_json(), + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL + ) + .is_some(), + "visible tool from the app-only tool's connector should be declared" + ); + assert!( + namespace_child_tool( + &requests[0].body_json(), + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_APP_ONLY_TOOL + ) + .is_none(), + "app-only tool should not be declared to a direct model" + ); + assert!( + requests[1] + .function_call_output(call_id) + .get("output") + .and_then(Value::as_str) + .is_some_and(|output| output.contains("unsupported call")), + "forced app-only direct call should not dispatch" + ); + assert!( + recorded_apps_tool_calls(&server).await.is_empty(), + "forced app-only direct call should not reach the MCP server" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn app_search_sources_are_hidden_for_api_key_auth() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap index 2edd63d3d2..679081353e 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap @@ -19,8 +19,6 @@ Scenario: Pre-turn auto-compaction with a context override emits the context dif 02:message/user:\nPRE_TURN_SUMMARY 03:message/developer: 04:message/user: -05:message/user[4]: - [01] - [02] - [03] - [04] USER_THREE +05:message/user[2]: + [01] + [02] USER_THREE diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 99b7fb0ec6..85c1e08c84 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -93,6 +93,104 @@ async fn new_thread_is_recorded_in_state_db() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled() -> Result<()> { + let server = start_mock_server().await; + let mock = mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + responses::sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + + let dynamic_tool = DynamicToolSpec { + namespace: None, + name: "resume_lookup".to_string(), + description: "Look up a value after resume.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { "query": { "type": "string" } }, + "required": ["query"], + "additionalProperties": false, + }), + defer_loading: false, + }; + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); + }); + let base_test = builder.build(&server).await?; + let started = base_test + .thread_manager + .start_thread_with_tools( + base_test.config.clone(), + vec![dynamic_tool.clone()], + /*persist_extended_history*/ false, + ) + .await?; + let rollout_path = started + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + started + .thread + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "persist this thread".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + wait_for_event(&started.thread, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let mut resume_builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); + }); + let resumed = resume_builder + .resume(&server, base_test.home.clone(), rollout_path) + .await?; + resumed.submit_turn("use the restored tool").await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + let resumed_body = requests[1].body_json(); + let tools = resumed_body + .get("tools") + .and_then(serde_json::Value::as_array) + .expect("resumed request tools"); + let restored_tool = tools + .iter() + .find(|tool| tool.get("name") == Some(&json!(dynamic_tool.name.as_str()))) + .expect("dynamic tool should be restored from rollout metadata"); + assert_eq!( + restored_tool.get("description"), + Some(&json!(dynamic_tool.description.as_str())) + ); + assert_eq!( + restored_tool.get("parameters"), + Some(&dynamic_tool.input_schema) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn backfill_scans_existing_rollouts() -> Result<()> { let server = start_mock_server().await; @@ -102,32 +200,6 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { let rollout_rel_path = format!("sessions/2026/01/27/rollout-2026-01-27T12-00-00-{uuid}.jsonl"); let rollout_rel_path_for_hook = rollout_rel_path.clone(); - let dynamic_tools = vec![ - DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: "geo_lookup".to_string(), - description: "lookup a city".to_string(), - input_schema: json!({ - "type": "object", - "required": ["city"], - "properties": { "city": { "type": "string" } } - }), - defer_loading: true, - }, - DynamicToolSpec { - namespace: None, - name: "weather_lookup".to_string(), - description: "lookup weather".to_string(), - input_schema: json!({ - "type": "object", - "required": ["zip"], - "properties": { "zip": { "type": "string" } } - }), - defer_loading: false, - }, - ]; - let dynamic_tools_for_hook = dynamic_tools.clone(); - let mut builder = test_codex() .with_pre_build_hook(move |codex_home| { let rollout_path = codex_home.join(&rollout_rel_path_for_hook); @@ -150,7 +222,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { agent_role: None, model_provider: None, base_instructions: None, - dynamic_tools: Some(dynamic_tools_for_hook), + dynamic_tools: None, memory_mode: None, }, git: None, @@ -164,6 +236,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { RolloutLine { timestamp: "2026-01-27T12:00:01Z".to_string(), item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "hello from backfill".to_string(), images: None, local_images: Vec::new(), @@ -217,17 +290,6 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { assert_eq!(metadata.model_provider, default_provider); assert!(metadata.first_user_message.is_some()); - let mut stored_tools = None; - for _ in 0..40 { - stored_tools = db.get_dynamic_tools(thread_id).await?; - if stored_tools.is_some() { - break; - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - let stored_tools = stored_tools.expect("dynamic tools should be stored"); - assert_eq!(stored_tools, dynamic_tools); - Ok(()) } diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs index 69eb474aaf..6d0390d382 100644 --- a/codex-rs/debug-client/src/client.rs +++ b/codex-rs/debug-client/src/client.rs @@ -198,6 +198,7 @@ impl AppServerClient { request_id: request_id.clone(), params: TurnStartParams { thread_id: thread_id.to_string(), + client_user_message_id: None, input: vec![UserInput::Text { text, // Debug client sends plain text with no UI markup spans. @@ -312,10 +313,8 @@ impl AppServerClient { }; match message { - JSONRPCMessage::Response(response) => { - if &response.id == request_id { - return Ok(response); - } + JSONRPCMessage::Response(response) if &response.id == request_id => { + return Ok(response); } JSONRPCMessage::Request(request) => { let _ = handle_server_request(request, &stdin); diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index 468ac205f7..8fa1a9eb75 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -32,6 +32,16 @@ Agent Identity JWT in `CODEX_ACCESS_TOKEN` can opt into that auth path with `--use-agent-identity-auth`; Codex then registers an Agent task and sends the derived AgentAssertion headers on the registry request. +Alternatively, API users can instead use `CODEX_API_KEY`; +Codex sends it as a bearer token on the registration request. For example: + +```sh +CODEX_API_KEY="$OPENAI_API_KEY" \ +codex exec-server \ + --remote ... \ + --environment-id "$ENVIRONMENT_ID" +``` + Wire framing: - local websocket: one JSON-RPC message per websocket frame diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index aae415f047..e06a736c17 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -218,7 +218,7 @@ impl LazyRemoteExecServerClient { } let next_client = match self.cached_client() { - Some(client) + Some(_client) if matches!( &self.transport_params, ExecServerTransportParams::WebSocketUrl { .. } diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 76b0f22b2b..3356791d44 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -137,7 +137,7 @@ fn sandbox_cwd(sandbox: &FileSystemSandboxContext) -> Result Vec { let mut roots = Vec::new(); for path in std::iter::once(runtime_paths.codex_self_exe.as_path()) - .chain(runtime_paths.codex_linux_sandbox_exe.as_deref().into_iter()) + .chain(runtime_paths.codex_linux_sandbox_exe.as_deref()) { if let Some(parent) = path.parent() && let Ok(root) = AbsolutePathBuf::from_absolute_path(parent) diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index de09d94e7c..ae7242377f 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -38,7 +38,9 @@ impl EnvironmentRegistryClient { Ok(Self { base_url, auth_provider, - http: reqwest::Client::new(), + http: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?, }) } @@ -312,6 +314,41 @@ mod tests { ); } + #[tokio::test] + async fn register_environment_does_not_follow_redirects_with_auth_headers() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/cloud/environment/environment-requested/register")) + .and(header("authorization", "Bearer registry-token")) + .respond_with( + ResponseTemplate::new(302) + .insert_header("location", format!("{}/redirect-target", server.uri())), + ) + .mount(&server) + .await; + Mock::given(path("/redirect-target")) + .and(header("authorization", "Bearer registry-token")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let client = EnvironmentRegistryClient::new(server.uri(), static_registry_auth_provider()) + .expect("client"); + + let error = client + .register_environment("environment-requested") + .await + .expect_err("redirect response should not be followed"); + + assert!(matches!( + error, + ExecServerError::EnvironmentRegistryHttp { + status: StatusCode::FOUND, + .. + } + )); + } + #[test] fn debug_output_redacts_auth_provider() { let config = RemoteEnvironmentConfig::new( diff --git a/codex-rs/exec-server/src/server/transport.rs b/codex-rs/exec-server/src/server/transport.rs index 92b2c77aaa..57d7048e2b 100644 --- a/codex-rs/exec-server/src/server/transport.rs +++ b/codex-rs/exec-server/src/server/transport.rs @@ -1,9 +1,15 @@ use axum::Router; +use axum::body::Body; use axum::extract::ConnectInfo; use axum::extract::State; use axum::extract::ws::WebSocketUpgrade; +use axum::http::Request; use axum::http::StatusCode; +use axum::http::header::ORIGIN; +use axum::middleware; +use axum::middleware::Next; use axum::response::IntoResponse; +use axum::response::Response; use axum::routing::any; use axum::routing::get; use std::io::Write as _; @@ -13,6 +19,7 @@ use tokio::io::AsyncRead; use tokio::io::AsyncWrite; use tokio::net::TcpListener; use tracing::info; +use tracing::warn; use crate::ExecServerRuntimePaths; use crate::connection::JsonRpcConnection; @@ -123,6 +130,7 @@ async fn run_websocket_listener( let router = Router::new() .route("/", any(websocket_upgrade_handler)) .route("/readyz", get(readiness_handler)) + .layer(middleware::from_fn(reject_requests_with_origin_header)) .with_state(ExecServerWebSocketState { processor }); axum::serve( listener, @@ -141,6 +149,22 @@ async fn readiness_handler() -> StatusCode { StatusCode::OK } +async fn reject_requests_with_origin_header( + request: Request, + next: Next, +) -> Result { + if request.headers().contains_key(ORIGIN) { + warn!( + method = %request.method(), + uri = %request.uri(), + "rejecting exec-server websocket listener request with Origin header" + ); + Err(StatusCode::FORBIDDEN) + } else { + Ok(next.run(request).await) + } +} + async fn websocket_upgrade_handler( websocket: WebSocketUpgrade, ConnectInfo(peer_addr): ConnectInfo, diff --git a/codex-rs/exec-server/tests/websocket.rs b/codex-rs/exec-server/tests/websocket.rs index 03b2cabfcd..3d0bebf106 100644 --- a/codex-rs/exec-server/tests/websocket.rs +++ b/codex-rs/exec-server/tests/websocket.rs @@ -9,6 +9,12 @@ use codex_exec_server::InitializeParams; use codex_exec_server::InitializeResponse; use common::exec_server::exec_server; use pretty_assertions::assert_eq; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Error as WebSocketError; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::http::StatusCode; +use tokio_tungstenite::tungstenite::http::header::ORIGIN; use uuid::Uuid; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -96,3 +102,24 @@ async fn exec_server_accepts_binary_websocket_json() -> anyhow::Result<()> { server.shutdown().await?; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_server_rejects_browser_origin_websocket_handshake() -> anyhow::Result<()> { + let mut server = exec_server().await?; + let mut request = server.websocket_url().into_client_request()?; + request + .headers_mut() + .insert(ORIGIN, HeaderValue::from_static("https://evil.example")); + + let error = match connect_async(request).await { + Ok(_) => anyhow::bail!("browser-origin websocket handshake should be rejected"), + Err(error) => error, + }; + let WebSocketError::Http(response) = error else { + anyhow::bail!("browser-origin websocket handshake failed unexpectedly: {error}"); + }; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 2df97b5bd7..8d632396b0 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -779,6 +779,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { request_id: request_ids.next(), params: TurnStartParams { thread_id: primary_thread_id_for_span.clone(), + client_user_message_id: None, input: items.into_iter().map(Into::into).collect(), responsesapi_client_metadata: None, additional_context: None, diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 2113fdef8e..71007e3817 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -16,6 +16,7 @@ mod turn_lifecycle; pub use prompt::PromptFragment; pub use prompt::PromptSlot; +pub use thread_lifecycle::ThreadIdleInput; pub use thread_lifecycle::ThreadResumeInput; pub use thread_lifecycle::ThreadStartInput; pub use thread_lifecycle::ThreadStopInput; @@ -25,6 +26,7 @@ pub use tool_lifecycle::ToolFinishInput; pub use tool_lifecycle::ToolLifecycleFuture; pub use tool_lifecycle::ToolStartInput; pub use turn_lifecycle::TurnAbortInput; +pub use turn_lifecycle::TurnErrorInput; pub use turn_lifecycle::TurnStartInput; pub use turn_lifecycle::TurnStopInput; @@ -51,6 +53,13 @@ pub trait ThreadLifecycleContributor: Send + Sync { /// Called after the host constructs a runtime from persisted history. async fn on_thread_resume(&self, _input: ThreadResumeInput<'_>) {} + /// Called after the host has drained immediately pending thread work. + /// + /// Implementations may use host capabilities captured by the extension to + /// submit follow-up input. The host remains responsible for deciding + /// whether that input starts a turn, is queued, or is ignored. + async fn on_thread_idle(&self, _input: ThreadIdleInput<'_>) {} + /// Called before the host drops the thread runtime and thread-scoped store. async fn on_thread_stop(&self, _input: ThreadStopInput<'_>) {} } @@ -71,6 +80,9 @@ pub trait TurnLifecycleContributor: Send + Sync { /// Called after the host aborts a running turn. async fn on_turn_abort(&self, _input: TurnAbortInput<'_>) {} + + /// Called when the host observes an error for a running turn. + async fn on_turn_error(&self, _input: TurnErrorInput<'_>) {} } /// Contributor for host-owned configuration changes. diff --git a/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs b/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs index d50f5a1e2f..5fbd1562d2 100644 --- a/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs +++ b/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs @@ -1,9 +1,14 @@ use crate::ExtensionData; +use codex_protocol::protocol::SessionSource; /// Input supplied when the host starts a runtime for a thread. pub struct ThreadStartInput<'a, C> { /// Host configuration visible at thread start. pub config: &'a C, + /// Source that created the session for this thread. + pub session_source: &'a SessionSource, + /// Whether persistent thread-scoped state is available for this thread. + pub persistent_thread_state_available: bool, /// Store scoped to the host session runtime. pub session_store: &'a ExtensionData, /// Store scoped to this thread runtime. @@ -18,6 +23,14 @@ pub struct ThreadResumeInput<'a> { pub thread_store: &'a ExtensionData, } +/// Input supplied when the host has no immediately pending thread work. +pub struct ThreadIdleInput<'a> { + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, +} + /// Input supplied when the host stops a thread runtime. pub struct ThreadStopInput<'a> { /// Store scoped to the host session runtime. diff --git a/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs b/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs index bbd3ae8f39..e2c9e4bf7b 100644 --- a/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs +++ b/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs @@ -1,4 +1,5 @@ use codex_protocol::config_types::CollaborationMode; +use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TurnAbortReason; @@ -41,3 +42,17 @@ pub struct TurnAbortInput<'a> { /// Store scoped to this turn runtime. pub turn_store: &'a ExtensionData, } + +/// Input supplied when the host observes an error for a turn. +pub struct TurnErrorInput<'a> { + /// Stable host-owned turn identifier. + pub turn_id: &'a str, + /// Error surfaced by the host for this turn. + pub error: CodexErrorInfo, + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, + /// Store scoped to this turn runtime. + pub turn_store: &'a ExtensionData, +} diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index a7c5c87e52..4705581c8a 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -11,8 +11,10 @@ pub use capabilities::NoopResponseItemInjector; pub use capabilities::ResponseItemInjectionFuture; pub use capabilities::ResponseItemInjector; pub use codex_tools::ConversationHistory; +pub use codex_tools::ExtensionTurnItem; pub use codex_tools::FunctionCallError; pub use codex_tools::JsonToolOutput; +pub use codex_tools::NoopTurnItemEmitter; pub use codex_tools::ResponsesApiTool; pub use codex_tools::ToolCall; pub use codex_tools::ToolExecutor; @@ -20,12 +22,16 @@ pub use codex_tools::ToolName; pub use codex_tools::ToolOutput; pub use codex_tools::ToolPayload; pub use codex_tools::ToolSpec; +pub use codex_tools::TurnItemEmissionFuture; +pub use codex_tools::TurnItemEmitter; pub use codex_tools::parse_tool_input_schema; +pub use codex_tools::parse_tool_input_schema_without_compaction; pub use contributors::ApprovalReviewContributor; pub use contributors::ConfigContributor; pub use contributors::ContextContributor; pub use contributors::PromptFragment; pub use contributors::PromptSlot; +pub use contributors::ThreadIdleInput; pub use contributors::ThreadLifecycleContributor; pub use contributors::ThreadResumeInput; pub use contributors::ThreadStartInput; @@ -39,6 +45,7 @@ pub use contributors::ToolLifecycleContributor; pub use contributors::ToolLifecycleFuture; pub use contributors::ToolStartInput; pub use contributors::TurnAbortInput; +pub use contributors::TurnErrorInput; pub use contributors::TurnItemContributor; pub use contributors::TurnLifecycleContributor; pub use contributors::TurnStartInput; diff --git a/codex-rs/ext/goal/src/accounting.rs b/codex-rs/ext/goal/src/accounting.rs index 2a679837cc..e0a84f5f8b 100644 --- a/codex-rs/ext/goal/src/accounting.rs +++ b/codex-rs/ext/goal/src/accounting.rs @@ -83,6 +83,17 @@ impl GoalAccountingState { self.inner().current_turn_id.clone() } + pub(crate) fn turn_is_current_active_goal(&self, turn_id: &str) -> bool { + let inner = self.inner(); + if inner.current_turn_id.as_deref() != Some(turn_id) { + return false; + } + let Some(turn) = inner.turns.get(turn_id) else { + return false; + }; + turn.account_tokens && turn.active_goal_id.is_some() + } + pub(crate) fn record_token_usage( &self, turn_id: impl Into, diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 68068acbb4..5421d17290 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -22,6 +22,8 @@ use codex_extension_api::TurnStartInput; use codex_extension_api::TurnStopInput; use codex_otel::MetricsClient; use codex_protocol::ThreadId; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::TokenUsageInfo; @@ -29,6 +31,7 @@ use crate::accounting::BudgetLimitedGoalDisposition; use crate::accounting::GoalAccountingState; use crate::events::GoalEventEmitter; use crate::metrics::GoalMetrics; +use crate::runtime::GoalRuntimeConfig; use crate::runtime::GoalRuntimeHandle; use crate::spec::UPDATE_GOAL_TOOL_NAME; use crate::steering::budget_limit_steering_item; @@ -85,6 +88,11 @@ where { async fn on_thread_start(&self, input: ThreadStartInput<'_, C>) { let enabled = (self.goals_enabled)(input.config); + let tools_available_for_thread = input.persistent_thread_state_available + && !matches!( + input.session_source, + SessionSource::SubAgent(SubAgentSource::Review) + ); input .thread_store .insert(GoalExtensionConfig::from_enabled(enabled)); @@ -102,7 +110,10 @@ where self.metrics.clone(), self.thread_manager.clone(), accounting_state, - enabled, + GoalRuntimeConfig { + enabled, + tools_available_for_thread, + }, ) }); runtime.set_enabled(enabled); @@ -330,7 +341,7 @@ where let Some(runtime) = goal_runtime_handle(thread_store) else { return Vec::new(); }; - if !runtime.is_enabled() { + if !runtime.tools_visible() { return Vec::new(); } diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index 0139cc2c52..d78eda3de5 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -20,6 +20,11 @@ pub struct GoalRuntimeHandle { inner: Arc, } +pub(crate) struct GoalRuntimeConfig { + pub(crate) enabled: bool, + pub(crate) tools_available_for_thread: bool, +} + struct GoalRuntimeInner { thread_id: ThreadId, state_dbs: Arc, @@ -28,6 +33,7 @@ struct GoalRuntimeInner { thread_manager: Weak, accounting_state: Arc, enabled: AtomicBool, + tools_available_for_thread: bool, } pub(crate) struct AccountedGoalProgress { @@ -66,7 +72,7 @@ impl GoalRuntimeHandle { metrics: GoalMetrics, thread_manager: Weak, accounting_state: Arc, - enabled: bool, + config: GoalRuntimeConfig, ) -> Self { Self { inner: Arc::new(GoalRuntimeInner { @@ -76,7 +82,8 @@ impl GoalRuntimeHandle { metrics, thread_manager, accounting_state, - enabled: AtomicBool::new(enabled), + enabled: AtomicBool::new(config.enabled), + tools_available_for_thread: config.tools_available_for_thread, }), } } @@ -89,6 +96,10 @@ impl GoalRuntimeHandle { self.inner.enabled.load(Ordering::Relaxed) } + pub(crate) fn tools_visible(&self) -> bool { + self.is_enabled() && self.inner.tools_available_for_thread + } + pub(crate) fn thread_id(&self) -> ThreadId { self.inner.thread_id } @@ -192,6 +203,54 @@ impl GoalRuntimeHandle { Ok(()) } + pub async fn usage_limit_active_goal_for_turn(&self, turn_id: &str) -> Result<(), String> { + if !self.is_enabled() { + return Ok(()); + } + + if !self + .inner + .accounting_state + .turn_is_current_active_goal(turn_id) + { + return Ok(()); + } + + let progress_event_id = format!("{turn_id}:usage-limit-progress"); + self.account_active_goal_progress( + turn_id, + progress_event_id.as_str(), + codex_state::GoalAccountingMode::ActiveOnly, + BudgetLimitedGoalDisposition::ClearActive, + ) + .await?; + + let previous_status = self + .current_goal_status_for_metrics(/*expected_goal_id*/ None) + .await?; + let Some(goal) = self + .inner + .state_dbs + .thread_goals() + .usage_limit_active_thread_goal(self.thread_id()) + .await + .map_err(|err| err.to_string())? + else { + return Ok(()); + }; + self.inner + .metrics + .record_terminal_if_status_changed(previous_status, &goal); + self.inner.accounting_state.clear_active_goal(); + let goal = protocol_goal_from_state(goal); + self.inner.event_emitter.thread_goal_updated( + format!("{turn_id}:usage-limit"), + Some(turn_id.to_string()), + goal, + ); + Ok(()) + } + pub async fn restore_after_resume(&self) -> Result<(), String> { if !self.is_enabled() { return Ok(()); diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index 0a36439b25..7524d40fe1 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -8,6 +8,7 @@ use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::FunctionCallError; +use codex_extension_api::NoopTurnItemEmitter; use codex_extension_api::ThreadResumeInput; use codex_extension_api::ThreadStartInput; use codex_extension_api::ToolCall; @@ -28,6 +29,7 @@ use codex_protocol::config_types::Settings; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -83,6 +85,38 @@ async fn installed_goal_tools_create_goal_and_fill_empty_preview() -> anyhow::Re Ok(()) } +#[tokio::test] +async fn goal_tools_hidden_for_ephemeral_threads() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + let tools = installed_tools_with_start( + runtime, + thread_id, + SessionSource::Cli, + /*persistent_thread_state_available*/ false, + ) + .await; + + assert_eq!(Vec::::new(), tool_names(&tools)); + Ok(()) +} + +#[tokio::test] +async fn goal_tools_hidden_for_review_subagents() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + let tools = installed_tools_with_start( + runtime, + thread_id, + SessionSource::SubAgent(SubAgentSource::Review), + /*persistent_thread_state_available*/ true, + ) + .await; + + assert_eq!(Vec::::new(), tool_names(&tools)); + Ok(()) +} + #[tokio::test] async fn installed_goal_tools_reject_duplicate_goal_creation() -> anyhow::Result<()> { let runtime = test_runtime().await?; @@ -363,6 +397,243 @@ async fn budget_limited_goal_keeps_accounting_after_later_tool_finish() -> anyho Ok(()) } +#[tokio::test] +async fn usage_limit_active_goal_accounts_progress_and_clears_accounting() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + harness.start_turn("turn-1", &TokenUsage::default()).await; + + let tools = harness.tools(); + let create_tool = tool_by_name(&tools, "create_goal"); + create_tool + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ "objective": "ship goal extension backend" }), + )) + .await?; + harness.sink.clear(); + + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 20, /*cached_input_tokens*/ 5, /*output_tokens*/ 8, + /*reasoning_output_tokens*/ 2, /*total_tokens*/ 30, + ), + ) + .await; + harness + .runtime_handle() + .usage_limit_active_goal_for_turn("turn-1") + .await + .map_err(anyhow::Error::msg)?; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(23, goal.tokens_used); + assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status); + assert_eq!( + vec![ + CapturedGoalEvent { + event_id: "turn-1:usage-limit-progress".to_string(), + turn_id: Some("turn-1".to_string()), + status: ThreadGoalStatus::Active, + tokens_used: 23, + }, + CapturedGoalEvent { + event_id: "turn-1:usage-limit".to_string(), + turn_id: Some("turn-1".to_string()), + status: ThreadGoalStatus::UsageLimited, + tokens_used: 23, + }, + ], + harness.sink.goal_events() + ); + + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 50, /*cached_input_tokens*/ 5, + /*output_tokens*/ 20, /*reasoning_output_tokens*/ 0, + /*total_tokens*/ 70, + ), + ) + .await; + harness + .notify_tool_finish("turn-1", "call-shell-after-usage-limit", "shell") + .await; + harness.stop_turn("turn-1").await; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(23, goal.tokens_used); + assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status); + Ok(()) +} + +#[tokio::test] +async fn usage_limit_budget_limited_goal_accounts_remaining_progress() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + harness.start_turn("turn-1", &TokenUsage::default()).await; + + let tools = harness.tools(); + let create_tool = tool_by_name(&tools, "create_goal"); + create_tool + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ + "objective": "ship goal extension backend", + "token_budget": 25, + }), + )) + .await?; + + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 20, /*cached_input_tokens*/ 5, + /*output_tokens*/ 10, /*reasoning_output_tokens*/ 0, + /*total_tokens*/ 30, + ), + ) + .await; + harness + .notify_tool_finish("turn-1", "call-shell", "shell") + .await; + harness.sink.clear(); + + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 24, /*cached_input_tokens*/ 5, + /*output_tokens*/ 16, /*reasoning_output_tokens*/ 0, + /*total_tokens*/ 40, + ), + ) + .await; + harness + .runtime_handle() + .usage_limit_active_goal_for_turn("turn-1") + .await + .map_err(anyhow::Error::msg)?; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(35, goal.tokens_used); + assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status); + assert_eq!( + vec![ + CapturedGoalEvent { + event_id: "turn-1:usage-limit-progress".to_string(), + turn_id: Some("turn-1".to_string()), + status: ThreadGoalStatus::BudgetLimited, + tokens_used: 35, + }, + CapturedGoalEvent { + event_id: "turn-1:usage-limit".to_string(), + turn_id: Some("turn-1".to_string()), + status: ThreadGoalStatus::UsageLimited, + tokens_used: 35, + }, + ], + harness.sink.goal_events() + ); + Ok(()) +} + +#[tokio::test] +async fn usage_limit_plan_turn_does_not_stop_goal() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + + let tools = harness.tools(); + let create_tool = tool_by_name(&tools, "create_goal"); + create_tool + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ "objective": "ship goal extension backend" }), + )) + .await?; + + harness + .start_turn_with_mode("turn-plan", ModeKind::Plan, &TokenUsage::default()) + .await; + harness.sink.clear(); + harness + .runtime_handle() + .usage_limit_active_goal_for_turn("turn-plan") + .await + .map_err(anyhow::Error::msg)?; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(codex_state::ThreadGoalStatus::Active, goal.status); + assert_eq!(Vec::::new(), harness.sink.goal_events()); + Ok(()) +} + +#[tokio::test] +async fn usage_limit_stale_turn_does_not_stop_current_goal() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + harness.start_turn("turn-1", &TokenUsage::default()).await; + + let tools = harness.tools(); + let create_tool = tool_by_name(&tools, "create_goal"); + create_tool + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ "objective": "ship goal extension backend" }), + )) + .await?; + harness.stop_turn("turn-1").await; + harness.start_turn("turn-2", &TokenUsage::default()).await; + harness.sink.clear(); + + harness + .runtime_handle() + .usage_limit_active_goal_for_turn("turn-1") + .await + .map_err(anyhow::Error::msg)?; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(codex_state::ThreadGoalStatus::Active, goal.status); + assert_eq!(Vec::::new(), harness.sink.goal_events()); + Ok(()) +} + #[tokio::test] async fn update_goal_can_block_and_accounts_final_progress() -> anyhow::Result<()> { let runtime = test_runtime().await?; @@ -640,6 +911,21 @@ async fn thread_resume_rehydrates_active_goal_idle_accounting() -> anyhow::Resul async fn installed_tools( runtime: Arc, thread_id: ThreadId, +) -> Vec>> { + installed_tools_with_start( + runtime, + thread_id, + SessionSource::Cli, + /*persistent_thread_state_available*/ true, + ) + .await +} + +async fn installed_tools_with_start( + runtime: Arc, + thread_id: ThreadId, + session_source: SessionSource, + persistent_thread_state_available: bool, ) -> Vec>> { let mut builder = ExtensionRegistryBuilder::<()>::new(); install_with_backend( @@ -656,6 +942,8 @@ async fn installed_tools( contributor .on_thread_start(ThreadStartInput { config: &(), + session_source: &session_source, + persistent_thread_state_available, session_store: &session_store, thread_store: &thread_store, }) @@ -669,6 +957,10 @@ async fn installed_tools( .collect() } +fn tool_names(tools: &[Arc>]) -> Vec { + tools.iter().map(|tool| tool.tool_name().name).collect() +} + struct GoalExtensionHarness { registry: codex_extension_api::ExtensionRegistry<()>, session_store: ExtensionData, @@ -693,10 +985,13 @@ impl GoalExtensionHarness { let registry = builder.build(); let session_store = ExtensionData::new("session-1"); let thread_store = ExtensionData::new(thread_id.to_string()); + let session_source = SessionSource::Cli; for contributor in registry.thread_lifecycle_contributors() { contributor .on_thread_start(ThreadStartInput { config: &(), + session_source: &session_source, + persistent_thread_state_available: true, session_store: &session_store, thread_store: &thread_store, }) @@ -719,8 +1014,14 @@ impl GoalExtensionHarness { } async fn start_turn(&self, turn_id: &str, usage: &TokenUsage) { + self.start_turn_with_mode(turn_id, ModeKind::Default, usage) + .await; + } + + async fn start_turn_with_mode(&self, turn_id: &str, mode: ModeKind, usage: &TokenUsage) { let turn_store = ExtensionData::new(turn_id); - let collaboration_mode = default_collaboration_mode(); + let mut collaboration_mode = default_collaboration_mode(); + collaboration_mode.mode = mode; for contributor in self.registry.turn_lifecycle_contributors() { contributor .on_turn_start(TurnStartInput { @@ -821,6 +1122,7 @@ fn tool_call(tool_name: &str, call_id: &str, arguments: serde_json::Value) -> To tool_name: codex_extension_api::ToolName::plain(tool_name), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), payload: ToolPayload::Function { arguments: arguments.to_string(), }, diff --git a/codex-rs/ext/image-generation/BUILD.bazel b/codex-rs/ext/image-generation/BUILD.bazel new file mode 100644 index 0000000000..5ed05a5dc8 --- /dev/null +++ b/codex-rs/ext/image-generation/BUILD.bazel @@ -0,0 +1,9 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "image-generation", + crate_name = "codex_image_generation_extension", + compile_data = [ + "imagegen_description.md", + ], +) diff --git a/codex-rs/ext/image-generation/Cargo.toml b/codex-rs/ext/image-generation/Cargo.toml new file mode 100644 index 0000000000..d4f2cb50de --- /dev/null +++ b/codex-rs/ext/image-generation/Cargo.toml @@ -0,0 +1,37 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-image-generation-extension" +version.workspace = true + +[lib] +name = "codex_image_generation_extension" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +base64 = { workspace = true } +codex-api = { workspace = true } +codex-core = { workspace = true } +codex-extension-api = { workspace = true } +codex-features = { workspace = true } +codex-login = { workspace = true } +codex-model-provider = { workspace = true } +codex-model-provider-info = { workspace = true } +codex-protocol = { workspace = true } +codex-tools = { workspace = true } +http = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["fs"] } +tracing = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/ext/image-generation/imagegen_description.md b/codex-rs/ext/image-generation/imagegen_description.md new file mode 100644 index 0000000000..7ae6ecb5fa --- /dev/null +++ b/codex-rs/ext/image-generation/imagegen_description.md @@ -0,0 +1,11 @@ +The `image_gen.imagegen` tool enables image generation from descriptions and editing of existing images based on specific instructions. Use it when: + +- The user requests an image based on a scene description, such as a diagram, portrait, comic, meme, or any other visual. +- The user wants to modify an attached or previously generated image with specific changes, including adding or removing elements, altering colors, improving quality/resolution, or transforming the style (e.g., cartoon, oil painting). + +Guidelines: +- Set `action` to `generate` when the user asks for a brand new image. +- Set `action` to `edit` when the user asks to modify an existing image from the conversation history. +- Directly generate the image without reconfirmation or clarification. +- After each image generation, do not mention anything related to download. Do not summarize the image. Do not ask followup question. Do not say ANYTHING after you generate an image. +- Always use this tool for image editing unless the user explicitly requests otherwise. Do not use the `python` tool for image editing unless specifically instructed. diff --git a/codex-rs/ext/image-generation/src/backend.rs b/codex-rs/ext/image-generation/src/backend.rs new file mode 100644 index 0000000000..9b837772c7 --- /dev/null +++ b/codex-rs/ext/image-generation/src/backend.rs @@ -0,0 +1,60 @@ +use codex_api::ImageEditRequest; +use codex_api::ImageGenerationRequest; +use codex_api::ImageResponse; +use codex_api::ImagesClient; +use codex_api::ReqwestTransport; +use codex_login::default_client::build_reqwest_client; +use codex_model_provider::SharedModelProvider; +use http::HeaderMap; + +#[derive(Clone)] +pub(crate) struct CodexImagesBackend { + provider: SharedModelProvider, +} + +impl CodexImagesBackend { + /// Creates a backend that sends image requests through the active model provider. + pub(crate) fn new(provider: SharedModelProvider) -> Self { + Self { provider } + } + + /// Resolves the provider and auth required for the current image API request. + async fn client(&self) -> Result, String> { + let provider = self + .provider + .api_provider() + .await + .map_err(|err| err.to_string())?; + let auth = self + .provider + .api_auth() + .await + .map_err(|err| err.to_string())?; + Ok(ImagesClient::new( + ReqwestTransport::new(build_reqwest_client()), + provider, + auth, + )) + } + + /// Sends a standalone image generation request through the configured Images client. + pub(crate) async fn generate( + &self, + request: ImageGenerationRequest, + ) -> Result { + self.client() + .await? + .generate(&request, HeaderMap::new()) + .await + .map_err(|err| err.to_string()) + } + + /// Sends a standalone image edit request through the configured Images client. + pub(crate) async fn edit(&self, request: ImageEditRequest) -> Result { + self.client() + .await? + .edit(&request, HeaderMap::new()) + .await + .map_err(|err| err.to_string()) + } +} diff --git a/codex-rs/ext/image-generation/src/extension.rs b/codex-rs/ext/image-generation/src/extension.rs new file mode 100644 index 0000000000..8f0b09f8cb --- /dev/null +++ b/codex-rs/ext/image-generation/src/extension.rs @@ -0,0 +1,99 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use codex_core::config::Config; +use codex_extension_api::ConfigContributor; +use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::ThreadLifecycleContributor; +use codex_extension_api::ThreadStartInput; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolContributor; +use codex_extension_api::ToolExecutor; +use codex_features::Feature; +use codex_login::AuthManager; +use codex_model_provider::create_model_provider; +use codex_model_provider_info::ModelProviderInfo; + +use crate::backend::CodexImagesBackend; +use crate::tool::ImageGenerationTool; +use crate::tool::generated_image_output_dir; + +#[derive(Clone)] +struct ImageGenerationExtension { + auth_manager: Arc, +} + +#[derive(Clone)] +struct ImageGenerationExtensionConfig { + enabled: bool, + provider: ModelProviderInfo, + codex_home: PathBuf, +} + +impl From<&Config> for ImageGenerationExtensionConfig { + /// Resolves whether standalone image generation should be available for a thread. + fn from(config: &Config) -> Self { + Self { + enabled: config.features.enabled(Feature::ImageGenExt) + && config.model_provider.is_openai(), + provider: config.model_provider.clone(), + codex_home: config.codex_home.to_path_buf(), + } + } +} + +#[async_trait::async_trait] +impl ThreadLifecycleContributor for ImageGenerationExtension { + /// Seeds image-generation availability when a thread begins. + async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { + input + .thread_store + .insert(ImageGenerationExtensionConfig::from(input.config)); + } +} + +impl ConfigContributor for ImageGenerationExtension { + /// Refreshes image-generation availability after thread configuration changes. + fn on_config_changed( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + _previous_config: &Config, + new_config: &Config, + ) { + thread_store.insert(ImageGenerationExtensionConfig::from(new_config)); + } +} + +impl ToolContributor for ImageGenerationExtension { + /// Creates the image-generation tool exposed by this installed extension. + fn tools( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + ) -> Vec>> { + let Some(config) = thread_store.get::() else { + return Vec::new(); + }; + if !config.enabled || !self.auth_manager.current_auth_uses_codex_backend() { + return Vec::new(); + } + + vec![Arc::new(ImageGenerationTool::new( + CodexImagesBackend::new(create_model_provider( + config.provider.clone(), + Some(self.auth_manager.clone()), + )), + generated_image_output_dir(&config.codex_home, thread_store.level_id()), + ))] + } +} + +/// Installs the feature-gated standalone image-generation extension contributors. +pub fn install(registry: &mut ExtensionRegistryBuilder, auth_manager: Arc) { + let extension = Arc::new(ImageGenerationExtension { auth_manager }); + registry.thread_lifecycle_contributor(extension.clone()); + registry.config_contributor(extension.clone()); + registry.tool_contributor(extension); +} diff --git a/codex-rs/ext/image-generation/src/lib.rs b/codex-rs/ext/image-generation/src/lib.rs new file mode 100644 index 0000000000..63f1ab2482 --- /dev/null +++ b/codex-rs/ext/image-generation/src/lib.rs @@ -0,0 +1,8 @@ +mod backend; +mod extension; +mod tool; + +pub use extension::install; + +pub(crate) const IMAGE_GEN_NAMESPACE: &str = "image_gen"; +pub(crate) const IMAGEGEN_TOOL_NAME: &str = "imagegen"; diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs new file mode 100644 index 0000000000..1b56270ed3 --- /dev/null +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -0,0 +1,341 @@ +use codex_api::ImageBackground; +use codex_api::ImageEditRequest; +use codex_api::ImageGenerationRequest; +use codex_api::ImageQuality; +use codex_api::ImageUrl; +use codex_extension_api::ToolOutput; +use codex_extension_api::ToolPayload; +use codex_extension_api::ToolSpec; +use codex_protocol::models::ContentItem; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; +use codex_tools::ResponsesApiNamespaceTool; +use pretty_assertions::assert_eq; + +use super::GeneratedImageOutput; +use super::ImageRequest; +use super::ImagegenAction; +use super::ImagegenArgs; +use super::generated_image_output_dir; +use super::imagegen_tool_spec; +use super::persist_generated_image; +use super::request_for_action; +use crate::IMAGE_GEN_NAMESPACE; +use crate::IMAGEGEN_TOOL_NAME; + +const RESULT: &str = "cG5n"; + +#[test] +fn uses_reserved_image_gen_namespace() { + let ToolSpec::Namespace(spec) = imagegen_tool_spec() else { + panic!("imagegen should advertise a namespace tool"); + }; + assert_eq!(spec.name, IMAGE_GEN_NAMESPACE); + let ResponsesApiNamespaceTool::Function(function) = &spec.tools[0]; + assert_eq!(function.name, IMAGEGEN_TOOL_NAME); +} + +#[test] +fn generate_uses_fixed_request_defaults() { + assert_eq!( + request_for_action(&args(ImagegenAction::Generate, "paint a moonlit lake"), &[]) + .expect("generation request should build"), + ImageRequest::Generate(ImageGenerationRequest { + prompt: "paint a moonlit lake".to_string(), + background: Some(ImageBackground::Auto), + model: "gpt-image-2".to_string(), + n: None, + quality: Some(ImageQuality::Auto), + size: Some("auto".to_string()), + }) + ); +} + +#[tokio::test] +async fn generated_output_returns_image_input_and_persists_artifact() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let output_hint = persist_generated_image(tempdir.path(), "call-1", RESULT) + .await + .expect("generated image should persist"); + let output = GeneratedImageOutput { + result: RESULT.to_string(), + output_hint: Some(output_hint), + }; + + let ResponseInputItem::FunctionCallOutput { + output: response_output, + .. + } = output.to_response_item("call-1", &function_payload()) + else { + panic!("imagegen should return function tool output"); + }; + let FunctionCallOutputBody::ContentItems(content_items) = response_output.body else { + panic!("imagegen output should contain generated image bytes"); + }; + assert_eq!( + content_items, + vec![ + FunctionCallOutputContentItem::InputImage { + image_url: format!("data:image/png;base64,{RESULT}"), + detail: Some(DEFAULT_IMAGE_DETAIL), + }, + FunctionCallOutputContentItem::InputText { + text: format!( + "Generated images are saved to {} as {} by default.\n\ + If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.", + tempdir.path().display(), + tempdir.path().join("call-1.png").display(), + ), + }, + ] + ); + assert_eq!( + std::fs::read(tempdir.path().join("call-1.png")).expect("saved generated image"), + b"png" + ); +} + +#[test] +fn edit_matches_context_selector_for_generated_images_after_latest_user_anchor() { + let history = vec![ + generated_item("g1"), + generated_item("g2"), + generated_item("g3"), + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputImage { + image_url: "data:image/png;base64,u1".to_string(), + detail: None, + }, + ContentItem::InputImage { + image_url: "data:image/png;base64,u2".to_string(), + detail: None, + }, + ], + phase: None, + }, + generated_item("g4"), + generated_item("g5"), + generated_item("g6"), + generated_item("g7"), + ]; + + assert_eq!( + edit_request("change the lighting", &history), + expected_edit_request( + "change the lighting", + &[ + "data:image/png;base64,u1", + "data:image/png;base64,u2", + "data:image/png;base64,g5", + "data:image/png;base64,g6", + "data:image/png;base64,g7", + ] + ) + ); +} + +#[test] +fn edit_preserves_a_generated_image_when_user_anchor_fills_the_limit() { + let history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: ["a", "b", "c", "d", "e"] + .into_iter() + .map(|image| ContentItem::InputImage { + image_url: format!("data:image/png;base64,{image}"), + detail: None, + }) + .collect(), + phase: None, + }, + generated_item("generated"), + ]; + + assert_eq!( + edit_request("edit the last generated image", &history), + expected_edit_request( + "edit the last generated image", + &[ + "data:image/png;base64,b", + "data:image/png;base64,c", + "data:image/png;base64,d", + "data:image/png;base64,e", + "data:image/png;base64,generated", + ] + ) + ); +} + +#[test] +fn edit_uses_latest_user_upload_before_a_text_only_follow_up() { + let history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputImage { + image_url: "data:image/png;base64,user".to_string(), + detail: None, + }], + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "edit this image".to_string(), + }], + phase: None, + }, + ]; + + assert_eq!( + edit_request("change the lighting", &history), + expected_edit_request("change the lighting", &["data:image/png;base64,user"]) + ); +} + +#[test] +fn edit_reuses_images_from_prior_standalone_imagegen_calls() { + let history = vec![ + ResponseItem::FunctionCall { + id: None, + name: IMAGEGEN_TOOL_NAME.to_string(), + namespace: Some(IMAGE_GEN_NAMESPACE.to_string()), + arguments: "{}".to_string(), + call_id: "imagegen-1".to_string(), + }, + generated_function_output("imagegen-1", "standalone"), + ]; + + assert_eq!( + edit_request("change the lighting", &history), + expected_edit_request("change the lighting", &["data:image/png;base64,standalone"]) + ); +} + +#[test] +fn edit_keeps_newest_standalone_generated_images_when_over_limit() { + let history = (1..=6) + .flat_map(|index| { + let call_id = format!("imagegen-{index}"); + vec![ + ResponseItem::FunctionCall { + id: None, + name: IMAGEGEN_TOOL_NAME.to_string(), + namespace: Some(IMAGE_GEN_NAMESPACE.to_string()), + arguments: "{}".to_string(), + call_id: call_id.clone(), + }, + generated_function_output(&call_id, &index.to_string()), + ] + }) + .collect::>(); + + assert_eq!( + edit_request("change the lighting", &history), + expected_edit_request( + "change the lighting", + &[ + "data:image/png;base64,2", + "data:image/png;base64,3", + "data:image/png;base64,4", + "data:image/png;base64,5", + "data:image/png;base64,6", + ] + ) + ); +} + +#[test] +fn edit_without_image_history_returns_tool_error() { + let error = request_for_action(&args(ImagegenAction::Edit, "change the lighting"), &[]) + .expect_err("edit should require image context"); + + assert_eq!( + error.to_string(), + "image edit requested without any usable image in conversation history" + ); +} + +#[test] +fn generated_image_output_dir_is_scoped_to_sanitized_thread_id() { + assert_eq!( + generated_image_output_dir(std::path::Path::new("/tmp/codex-home"), "thread/1"), + std::path::PathBuf::from("/tmp/codex-home/generated_images/thread_1") + ); +} + +fn args(action: ImagegenAction, prompt: &str) -> ImagegenArgs { + ImagegenArgs { + prompt: prompt.to_string(), + action, + } +} + +fn edit_request(prompt: &str, history: &[ResponseItem]) -> ImageEditRequest { + let ImageRequest::Edit(request) = + request_for_action(&args(ImagegenAction::Edit, prompt), history) + .expect("edit request should build") + else { + panic!("expected edit request"); + }; + request +} + +fn expected_edit_request(prompt: &str, images: &[&str]) -> ImageEditRequest { + ImageEditRequest { + images: images + .iter() + .map(|image_url| ImageUrl { + image_url: (*image_url).to_string(), + }) + .collect(), + prompt: prompt.to_string(), + background: Some(ImageBackground::Auto), + model: "gpt-image-2".to_string(), + n: None, + quality: Some(ImageQuality::Auto), + size: Some("auto".to_string()), + } +} + +fn generated_item(result: &str) -> ResponseItem { + ResponseItem::ImageGenerationCall { + id: format!("id-{result}"), + status: "completed".to_string(), + revised_prompt: None, + result: result.to_string(), + } +} + +fn generated_function_output(call_id: &str, result: &str) -> ResponseItem { + ResponseItem::FunctionCallOutput { + call_id: call_id.to_string(), + output: FunctionCallOutputPayload { + body: FunctionCallOutputBody::ContentItems(vec![ + FunctionCallOutputContentItem::InputImage { + image_url: format!("data:image/png;base64,{result}"), + detail: Some(DEFAULT_IMAGE_DETAIL), + }, + FunctionCallOutputContentItem::InputText { + text: "generated image save hint".to_string(), + }, + ]), + success: Some(true), + }, + } +} + +fn function_payload() -> ToolPayload { + ToolPayload::Function { + arguments: "{}".to_string(), + } +} diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs new file mode 100644 index 0000000000..fa1614cd47 --- /dev/null +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -0,0 +1,395 @@ +use std::path::Path; +use std::path::PathBuf; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use codex_api::ImageBackground; +use codex_api::ImageEditRequest; +use codex_api::ImageGenerationRequest; +use codex_api::ImageQuality; +use codex_api::ImageUrl; +use codex_extension_api::FunctionCallError; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolExecutor; +use codex_extension_api::ToolName; +use codex_extension_api::ToolOutput; +use codex_extension_api::ToolPayload; +use codex_extension_api::ToolSpec; +use codex_extension_api::parse_tool_input_schema; +use codex_protocol::models::ContentItem; +use codex_protocol::models::DEFAULT_IMAGE_DETAIL; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; +use codex_tools::ResponsesApiNamespace; +use codex_tools::ResponsesApiNamespaceTool; +use codex_tools::ResponsesApiTool; +use codex_tools::ToolExposure; +use codex_tools::default_namespace_description; +use schemars::JsonSchema; +use schemars::r#gen::SchemaSettings; +use serde::Deserialize; +use serde_json::Map; +use serde_json::Value; + +use crate::IMAGE_GEN_NAMESPACE; +use crate::IMAGEGEN_TOOL_NAME; +use crate::backend::CodexImagesBackend; + +const IMAGE_MODEL: &str = "gpt-image-2"; +const MAX_EDIT_IMAGES: usize = 5; +const IMAGEGEN_DESCRIPTION: &str = include_str!("../imagegen_description.md"); +const GENERATED_IMAGE_ARTIFACTS_DIR: &str = "generated_images"; + +#[derive(Clone)] +pub(crate) struct ImageGenerationTool { + backend: CodexImagesBackend, + output_dir: PathBuf, +} + +impl ImageGenerationTool { + /// Creates an image-generation tool backed by an image API executor. + pub(crate) fn new(backend: CodexImagesBackend, output_dir: PathBuf) -> Self { + Self { + backend, + output_dir, + } + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct ImagegenArgs { + prompt: String, + action: ImagegenAction, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +enum ImagegenAction { + Generate, + Edit, +} + +#[async_trait::async_trait] +impl ToolExecutor for ImageGenerationTool { + /// Keeps the tool in the existing image-generation Responses namespace. + fn tool_name(&self) -> ToolName { + ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME) + } + + /// Advertises the model contract: a rewritten prompt and semantic action. + fn spec(&self) -> ToolSpec { + imagegen_tool_spec() + } + + /// Keeps this model-facing tool out of the nested code-mode tool surface. + fn exposure(&self) -> ToolExposure { + ToolExposure::DirectModelOnly + } + + /// Executes the selected image operation and returns the completed image result. + async fn handle(&self, call: ToolCall) -> Result, FunctionCallError> { + let args = parse_args(&call)?; + let request = request_for_action(&args, call.conversation_history.items())?; + + let response = match request { + ImageRequest::Generate(request) => self.backend.generate(request).await, + ImageRequest::Edit(request) => self.backend.edit(request).await, + } + .map_err(|err| { + FunctionCallError::RespondToModel(format!("image generation failed: {err}")) + })?; + let Some(result) = response.data.into_iter().next().map(|data| data.b64_json) else { + return Err(FunctionCallError::RespondToModel( + "image generation returned no image data".to_string(), + )); + }; + let output_hint = + match persist_generated_image(&self.output_dir, &call.call_id, &result).await { + Ok(output_hint) => Some(output_hint), + Err(err) => { + tracing::warn!( + call_id = %call.call_id, + output_dir = %self.output_dir.display(), + "failed to save generated image: {err}" + ); + None + } + }; + Ok(Box::new(GeneratedImageOutput { + result, + output_hint, + })) + } +} + +#[derive(Debug, PartialEq)] +enum ImageRequest { + Generate(ImageGenerationRequest), + Edit(ImageEditRequest), +} + +/// Maps the model-selected action to the fixed image API request parameters. +fn request_for_action( + args: &ImagegenArgs, + history: &[ResponseItem], +) -> Result { + match args.action { + ImagegenAction::Generate => Ok(ImageRequest::Generate(ImageGenerationRequest { + prompt: args.prompt.clone(), + background: Some(ImageBackground::Auto), + model: IMAGE_MODEL.to_string(), + n: None, + quality: Some(ImageQuality::Auto), + size: Some("auto".to_string()), + })), + ImagegenAction::Edit => { + let images = edit_images(history); + if images.is_empty() { + return Err(FunctionCallError::RespondToModel( + "image edit requested without any usable image in conversation history" + .to_string(), + )); + } + Ok(ImageRequest::Edit(ImageEditRequest { + images, + prompt: args.prompt.clone(), + background: Some(ImageBackground::Auto), + model: IMAGE_MODEL.to_string(), + n: None, + quality: Some(ImageQuality::Auto), + size: Some("auto".to_string()), + })) + } + } +} + +/// Selects edit context using the hosted imagegen anchor and truncation behavior. +fn edit_images(history: &[ResponseItem]) -> Vec { + let latest_uploaded_images = history.iter().enumerate().rev().find_map(|(index, item)| { + let ResponseItem::Message { role, content, .. } = item else { + return None; + }; + if role != "user" { + return None; + } + let images = content + .iter() + .filter_map(|item| match item { + ContentItem::InputImage { image_url, .. } => Some(ImageUrl { + image_url: image_url.clone(), + }), + ContentItem::InputText { .. } | ContentItem::OutputText { .. } => None, + }) + .collect::>(); + (!images.is_empty()).then_some((index, images)) + }); + let (user_images, follow_up_start) = latest_uploaded_images + .map_or_else(|| (Vec::new(), 0), |(index, images)| (images, index + 1)); + let mut generated_images = Vec::new(); + for item in &history[follow_up_start..] { + match item { + ResponseItem::ImageGenerationCall { result, .. } if !result.is_empty() => { + generated_images.push(ImageUrl { + image_url: format!("data:image/png;base64,{result}"), + }); + } + ResponseItem::FunctionCallOutput { call_id, output } + if history.iter().any(|item| { + matches!( + item, + ResponseItem::FunctionCall { + name, + namespace: Some(namespace), + call_id: function_call_id, + .. + } if function_call_id == call_id + && name == IMAGEGEN_TOOL_NAME + && namespace == IMAGE_GEN_NAMESPACE + ) + }) => + { + generated_images.extend(output.content_items().into_iter().flatten().filter_map( + |item| match item { + FunctionCallOutputContentItem::InputImage { image_url, .. } => { + Some(ImageUrl { + image_url: image_url.clone(), + }) + } + FunctionCallOutputContentItem::InputText { .. } + | FunctionCallOutputContentItem::EncryptedContent { .. } => None, + }, + )); + } + ResponseItem::Message { .. } + | ResponseItem::Reasoning { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger + | ResponseItem::ContextCompaction { .. } + | ResponseItem::Other => {} + } + } + truncate_images(user_images, generated_images) +} + +/// Truncates edit inputs while preserving the newest generated image when possible. +fn truncate_images( + mut user_images: Vec, + mut generated_images: Vec, +) -> Vec { + let mut excess = (user_images.len() + generated_images.len()).saturating_sub(MAX_EDIT_IMAGES); + let drop_generated = excess.min(generated_images.len().saturating_sub(1)); + generated_images.drain(..drop_generated); + excess -= drop_generated; + let drop_user = excess.min(user_images.len()); + user_images.drain(..drop_user); + excess -= drop_user; + generated_images.drain(..excess); + + user_images.extend(generated_images); + user_images +} + +/// Parses the strict model-facing arguments for an image-generation call. +fn parse_args(call: &ToolCall) -> Result { + serde_json::from_str(call.function_arguments()?) + .map_err(|err| FunctionCallError::RespondToModel(err.to_string())) +} + +/// Resolves where generated images for one thread are persisted by the extension. +pub(crate) fn generated_image_output_dir(codex_home: &Path, thread_id: &str) -> PathBuf { + codex_home + .join(GENERATED_IMAGE_ARTIFACTS_DIR) + .join(sanitize_path_component(thread_id)) +} + +fn generated_image_output_path(output_dir: &Path, call_id: &str) -> PathBuf { + output_dir.join(format!("{}.png", sanitize_path_component(call_id))) +} + +fn sanitize_path_component(value: &str) -> String { + let sanitized: String = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect(); + if sanitized.is_empty() { + "generated_image".to_string() + } else { + sanitized + } +} + +async fn persist_generated_image( + output_dir: &Path, + call_id: &str, + result: &str, +) -> Result { + let bytes = BASE64_STANDARD + .decode(result.trim().as_bytes()) + .map_err(|err| format!("invalid image generation payload: {err}"))?; + tokio::fs::create_dir_all(output_dir) + .await + .map_err(|err| err.to_string())?; + tokio::fs::write(generated_image_output_path(output_dir, call_id), bytes) + .await + .map_err(|err| err.to_string())?; + + Ok(format!( + "Generated images are saved to {} as {} by default.\n\ + If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.", + output_dir.display(), + generated_image_output_path(output_dir, call_id).display(), + )) +} + +/// Builds the namespace function schema exposed to the model. +fn imagegen_tool_spec() -> ToolSpec { + let mut schema_value = serde_json::to_value( + SchemaSettings::draft2019_09() + .with(|settings| settings.inline_subschemas = true) + .into_generator() + .into_root_schema_for::(), + ) + .unwrap_or_else(|err| panic!("imagegen schema should serialize: {err}")); + let Value::Object(ref mut schema) = schema_value else { + unreachable!("imagegen root schema must be an object"); + }; + let mut input_schema = Map::new(); + for key in ["properties", "required", "type", "additionalProperties"] { + if let Some(value) = schema.remove(key) { + input_schema.insert(key.to_string(), value); + } + } + ToolSpec::Namespace(ResponsesApiNamespace { + name: IMAGE_GEN_NAMESPACE.to_string(), + description: default_namespace_description(IMAGE_GEN_NAMESPACE), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: IMAGEGEN_TOOL_NAME.to_string(), + description: IMAGEGEN_DESCRIPTION.to_string(), + strict: false, + parameters: parse_tool_input_schema(&Value::Object(input_schema)) + .unwrap_or_else(|err| panic!("imagegen input schema should parse: {err}")), + output_schema: None, + defer_loading: None, + })], + }) +} + +struct GeneratedImageOutput { + result: String, + output_hint: Option, +} + +impl ToolOutput for GeneratedImageOutput { + /// Avoids copying image bytes into tool-call telemetry. + fn log_preview(&self) -> String { + "[generated image]".to_string() + } + + /// Reports a completed images request as successful tool execution. + fn success_for_logging(&self) -> bool { + true + } + + /// Returns generated bytes and persisted-artifact context for the model's follow-up response. + fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { + let mut content = vec![FunctionCallOutputContentItem::InputImage { + image_url: format!("data:image/png;base64,{}", self.result), + detail: Some(DEFAULT_IMAGE_DETAIL), + }]; + if let Some(output_hint) = &self.output_hint { + content.push(FunctionCallOutputContentItem::InputText { + text: output_hint.clone(), + }); + } + ResponseInputItem::FunctionCallOutput { + call_id: call_id.to_string(), + output: FunctionCallOutputPayload { + body: FunctionCallOutputBody::ContentItems(content), + success: Some(true), + }, + } + } +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/codex-rs/ext/memories/src/lib.rs b/codex-rs/ext/memories/src/lib.rs index d17c4c4956..25120c7a3a 100644 --- a/codex-rs/ext/memories/src/lib.rs +++ b/codex-rs/ext/memories/src/lib.rs @@ -15,7 +15,7 @@ pub(crate) const MAX_SEARCH_RESULTS: usize = 200; pub(crate) const DEFAULT_READ_MAX_TOKENS: usize = 20_000; pub(crate) const MEMORY_TOOL_DEVELOPER_INSTRUCTIONS_SUMMARY_TOKEN_LIMIT: usize = 2_500; -pub(crate) const MEMORY_TOOLS_NAMESPACE: &str = "memories/"; +pub(crate) const MEMORY_TOOLS_NAMESPACE: &str = "memories"; pub(crate) const ADD_AD_HOC_NOTE_TOOL_NAME: &str = "add_ad_hoc_note"; pub(crate) const LIST_TOOL_NAME: &str = "list"; pub(crate) const READ_TOOL_NAME: &str = "read"; diff --git a/codex-rs/ext/memories/src/metrics.rs b/codex-rs/ext/memories/src/metrics.rs index 610a3cb9ed..443156b724 100644 --- a/codex-rs/ext/memories/src/metrics.rs +++ b/codex-rs/ext/memories/src/metrics.rs @@ -15,7 +15,7 @@ pub(crate) fn record_tool_call( return; }; - let tool = format!("{MEMORY_TOOLS_NAMESPACE}{operation}"); + let tool = format!("{MEMORY_TOOLS_NAMESPACE}/{operation}"); let _ = metrics_client.counter( MEMORIES_TOOL_CALL_METRIC, /*inc*/ 1, diff --git a/codex-rs/ext/memories/src/tests.rs b/codex-rs/ext/memories/src/tests.rs index fbb23bd7ac..f2d79c850c 100644 --- a/codex-rs/ext/memories/src/tests.rs +++ b/codex-rs/ext/memories/src/tests.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use codex_extension_api::ContextContributor; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::NoopTurnItemEmitter; use codex_extension_api::PromptSlot; use codex_extension_api::ToolCall; use codex_extension_api::ToolContributor; @@ -22,6 +23,16 @@ use crate::extension::MemoriesExtension; use crate::extension::MemoriesExtensionConfig; use crate::local::LocalMemoriesBackend; +#[test] +fn memory_tool_namespace_matches_responses_api_identifier() { + assert!(!crate::MEMORY_TOOLS_NAMESPACE.is_empty()); + assert!( + crate::MEMORY_TOOLS_NAMESPACE + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + ); +} + #[test] fn tools_are_not_contributed_without_thread_config() { let extension = MemoriesExtension::default(); @@ -202,6 +213,7 @@ async fn add_ad_hoc_note_tool_creates_note_file() { tool_name: memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), payload: payload.clone(), }) .await @@ -243,6 +255,7 @@ async fn add_ad_hoc_note_tool_rejects_paths_as_filenames() { tool_name: memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), payload, }) .await; @@ -285,6 +298,7 @@ async fn read_tool_reads_memory_file() { tool_name: memory_tool_name(crate::READ_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), payload: payload.clone(), }) .await @@ -330,6 +344,7 @@ async fn search_tool_accepts_multiple_queries() { tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), payload: payload.clone(), }) .await @@ -401,6 +416,7 @@ async fn search_tool_accepts_windowed_all_match_mode() { tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), payload: payload.clone(), }) .await @@ -452,6 +468,7 @@ async fn search_tool_rejects_legacy_single_query() { tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), payload, }) .await; diff --git a/codex-rs/ext/web-search/src/tool.rs b/codex-rs/ext/web-search/src/tool.rs index 1e1db2deca..875476db9a 100644 --- a/codex-rs/ext/web-search/src/tool.rs +++ b/codex-rs/ext/web-search/src/tool.rs @@ -10,7 +10,7 @@ use codex_extension_api::ToolExecutor; use codex_extension_api::ToolName; use codex_extension_api::ToolOutput; use codex_extension_api::ToolSpec; -use codex_extension_api::parse_tool_input_schema; +use codex_extension_api::parse_tool_input_schema_without_compaction; use codex_login::default_client::build_reqwest_client; use codex_model_provider::SharedModelProvider; use codex_tools::ResponsesApiNamespace; @@ -40,7 +40,8 @@ impl ToolExecutor for WebSearchTool { } fn spec(&self) -> ToolSpec { - let parameters = match parse_tool_input_schema(&commands_schema()) { + // parse schema without compaction that removes field metadata/descriptions to match hosted tool definition + let parameters = match parse_tool_input_schema_without_compaction(&commands_schema()) { Ok(parameters) => parameters, Err(err) => panic!("search command schema should parse: {err}"), }; diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index 01220c07c1..e09805d4d2 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -78,6 +78,7 @@ fn rollout_items_from_messages(messages: &[ConversationMessage]) -> Vec Value { Value::Array(items) => Value::Array(items.iter().map(canonicalize_json).collect()), Value::Object(map) => { let mut entries: Vec<_> = map.iter().collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + entries.sort_by_key(|(key, _)| *key); let mut sorted = Map::with_capacity(map.len()); for (key, child) in entries { sorted.insert(key.clone(), canonicalize_json(child)); diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index d94c48165d..22e2c9f3b9 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -84,6 +84,7 @@ struct ChatgptAuthState { } const TOKEN_REFRESH_INTERVAL: i64 = 8; +const CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES: i64 = 5; const REFRESH_TOKEN_EXPIRED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token has expired. Please log out and sign in again."; const REFRESH_TOKEN_REUSED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again."; @@ -844,8 +845,8 @@ async fn request_chatgpt_token_refresh( } else { let body = response.text().await.unwrap_or_default(); tracing::error!("Failed to refresh token: {status}: {body}"); - if status == StatusCode::UNAUTHORIZED { - let failed = classify_refresh_token_failure(&body); + let failed = classify_refresh_token_failure(&body); + if status == StatusCode::UNAUTHORIZED || failed.reason != RefreshTokenFailedReason::Other { Err(RefreshTokenError::Permanent(failed)) } else { let message = try_parse_error_message(&body); @@ -871,7 +872,7 @@ fn classify_refresh_token_failure(body: &str) -> RefreshTokenFailedError { tracing::warn!( backend_code = normalized_code.as_deref(), backend_body = body, - "Encountered unknown 401 response while refreshing token" + "Encountered unknown response while refreshing token" ); } @@ -1421,15 +1422,15 @@ impl AuthManager { } /// Current cached auth (clone). May be `None` if not logged in or load failed. - /// For stale managed ChatGPT auth, first performs a guarded reload and then - /// refreshes only if the on-disk auth is unchanged. + /// For managed ChatGPT auth that needs a proactive refresh, first performs + /// a guarded reload and then refreshes only if the on-disk auth is unchanged. pub async fn auth(&self) -> Option { if let Some(auth) = self.resolve_external_api_key_auth().await { return Some(auth); } let auth = self.auth_cached()?; - if Self::is_stale_for_proactive_refresh(&auth) + if Self::should_refresh_proactively(&auth) && let Err(err) = self.refresh_token().await { tracing::error!("Failed to refresh token: {}", err); @@ -1808,7 +1809,7 @@ impl AuthManager { ) } - fn is_stale_for_proactive_refresh(auth: &CodexAuth) -> bool { + fn should_refresh_proactively(auth: &CodexAuth) -> bool { let chatgpt_auth = match auth { CodexAuth::Chatgpt(chatgpt_auth) => chatgpt_auth, _ => return false, @@ -1821,7 +1822,9 @@ impl AuthManager { if let Some(tokens) = auth_dot_json.tokens.as_ref() && let Ok(Some(expires_at)) = parse_jwt_expiration(&tokens.access_token) { - return expires_at <= Utc::now(); + return expires_at + <= Utc::now() + + chrono::Duration::minutes(CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES); } let last_refresh = match auth_dot_json.last_refresh { Some(last_refresh) => last_refresh, diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index afdf466d09..83897a0d38 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -158,6 +158,102 @@ async fn refresh_token_refreshes_when_auth_is_unchanged() -> Result<()> { Ok(()) } +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn auth_refreshes_when_access_token_is_near_expiry() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "new-access-token", + "refresh_token": "new-refresh-token" + }))) + .expect(1) + .mount(&server) + .await; + + let ctx = RefreshTokenTestContext::new(&server).await?; + let initial_last_refresh = Utc::now(); + let near_expiry_access_token = access_token_with_expiration(Utc::now() + Duration::minutes(4)); + let initial_tokens = build_tokens(&near_expiry_access_token, INITIAL_REFRESH_TOKEN); + let initial_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(initial_tokens.clone()), + last_refresh: Some(initial_last_refresh), + agent_identity: None, + }; + ctx.write_auth(&initial_auth).await?; + + let cached_auth = ctx + .auth_manager + .auth() + .await + .context("auth should be cached")?; + + let refreshed_tokens = TokenData { + access_token: "new-access-token".to_string(), + refresh_token: "new-refresh-token".to_string(), + ..initial_tokens.clone() + }; + let cached = cached_auth + .get_token_data() + .context("token data should refresh")?; + assert_eq!(cached, refreshed_tokens); + let stored = ctx.load_auth()?; + let tokens = stored.tokens.as_ref().context("tokens should exist")?; + assert_eq!(tokens, &refreshed_tokens); + let refreshed_at = stored + .last_refresh + .as_ref() + .context("last_refresh should be recorded")?; + assert!( + *refreshed_at >= initial_last_refresh, + "last_refresh should advance" + ); + + server.verify().await; + Ok(()) +} + +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn auth_skips_access_token_outside_refresh_window() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + let ctx = RefreshTokenTestContext::new(&server).await?; + let initial_last_refresh = Utc::now(); + let fresh_access_token = access_token_with_expiration(Utc::now() + Duration::minutes(6)); + let initial_tokens = build_tokens(&fresh_access_token, INITIAL_REFRESH_TOKEN); + let initial_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(initial_tokens.clone()), + last_refresh: Some(initial_last_refresh), + agent_identity: None, + }; + ctx.write_auth(&initial_auth).await?; + + let cached_auth = ctx + .auth_manager + .auth() + .await + .context("auth should be cached")?; + + let cached = cached_auth + .get_token_data() + .context("token data should remain cached")?; + assert_eq!(cached, initial_tokens); + assert_eq!(ctx.load_auth()?, initial_auth); + let requests = server.received_requests().await.unwrap_or_default(); + assert!(requests.is_empty(), "expected no refresh token requests"); + + Ok(()) +} + #[serial_test::serial(auth_refresh)] #[tokio::test] async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> { @@ -625,6 +721,73 @@ async fn refresh_token_does_not_retry_after_permanent_failure() -> Result<()> { Ok(()) } +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn refresh_token_does_not_retry_after_bad_request_reused_failure() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": { + "code": "refresh_token_reused" + } + }))) + .expect(1) + .mount(&server) + .await; + + let ctx = RefreshTokenTestContext::new(&server).await?; + let initial_last_refresh = Utc::now() - Duration::days(1); + let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); + let initial_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(initial_tokens.clone()), + last_refresh: Some(initial_last_refresh), + agent_identity: None, + }; + ctx.write_auth(&initial_auth).await?; + + let first_err = ctx + .auth_manager + .refresh_token() + .await + .err() + .context("first refresh should fail")?; + assert_eq!( + first_err.failed_reason(), + Some(RefreshTokenFailedReason::Exhausted) + ); + + let second_err = ctx + .auth_manager + .refresh_token() + .await + .err() + .context("second refresh should fail without retrying")?; + assert_eq!( + second_err.failed_reason(), + Some(RefreshTokenFailedReason::Exhausted) + ); + + let stored = ctx.load_auth()?; + assert_eq!(stored, initial_auth); + let cached_auth = ctx + .auth_manager + .auth() + .await + .context("auth should remain cached")?; + let cached = cached_auth + .get_token_data() + .context("token data should remain cached")?; + assert_eq!(cached, initial_tokens); + + server.verify().await; + Ok(()) +} + #[serial_test::serial(auth_refresh)] #[tokio::test] async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result<()> { diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 9c9a3da53c..f90686dbea 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -116,20 +116,13 @@ pub(crate) fn create_tool_for_codex_tool_call_param() -> Tool { let input_schema = create_tool_input_schema(schema, "Codex tool schema should serialize"); - Tool { - name: "codex".into(), - title: Some("Codex".to_string()), + Tool::new( + "codex", + "Run a Codex session. Accepts configuration parameters matching the Codex Config struct.", input_schema, - output_schema: Some(codex_tool_output_schema()), - description: Some( - "Run a Codex session. Accepts configuration parameters matching the Codex Config struct." - .into(), - ), - annotations: None, - execution: None, - icons: None, - meta: None, - } + ) + .with_title("Codex") + .with_raw_output_schema(codex_tool_output_schema()) } fn codex_tool_output_schema() -> Arc { @@ -242,19 +235,13 @@ pub(crate) fn create_tool_for_codex_tool_call_reply_param() -> Tool { let input_schema = create_tool_input_schema(schema, "Codex reply tool schema should serialize"); - Tool { - name: "codex-reply".into(), - title: Some("Codex Reply".to_string()), + Tool::new( + "codex-reply", + "Continue a Codex conversation by providing the thread id and prompt.", input_schema, - output_schema: Some(codex_tool_output_schema()), - description: Some( - "Continue a Codex conversation by providing the thread id and prompt.".into(), - ), - annotations: None, - execution: None, - icons: None, - meta: None, - } + ) + .with_title("Codex Reply") + .with_raw_output_schema(codex_tool_output_schema()) } fn create_tool_input_schema( diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 78714d1eaa..05b995e721 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -44,12 +44,10 @@ pub(crate) fn create_call_tool_result_with_thread_id( "threadId": thread_id, "content": content_text, }); - CallToolResult { - content, - is_error, - structured_content: Some(structured_content), - meta: None, - } + let mut result = CallToolResult::success(content); + result.is_error = is_error; + result.structured_content = Some(structured_content); + result } /// Run a complete Codex session and stream events back to the client. @@ -71,12 +69,9 @@ pub async fn run_codex_tool_session( } = match thread_manager.start_thread(config.clone()).await { Ok(res) => res, Err(e) => { - let result = CallToolResult { - content: vec![Content::text(format!("Failed to start Codex session: {e}"))], - is_error: Some(true), - structured_content: None, - meta: None, - }; + let result = CallToolResult::error(vec![Content::text(format!( + "Failed to start Codex session: {e}" + ))]); outgoing.send_response(id.clone(), result).await; return; } @@ -119,6 +114,7 @@ pub async fn run_codex_tool_session( additional_context: Default::default(), thread_settings: Default::default(), }, + client_user_message_id: None, trace: None, }; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 9e536d930c..2f85b35143 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -27,7 +27,6 @@ use rmcp::model::JsonRpcRequest; use rmcp::model::JsonRpcResponse; use rmcp::model::RequestId; use rmcp::model::ServerCapabilities; -use rmcp::model::ToolsCapability; use serde_json::json; use tokio::sync::Mutex; use tokio::task; @@ -218,14 +217,8 @@ impl MessageProcessor { *suffix = Some(user_agent_suffix); } - let server_info = Implementation { - name: "codex-mcp-server".to_string(), - title: Some("Codex".to_string()), - version: env!("CARGO_PKG_VERSION").to_string(), - description: None, - icons: None, - website_url: None, - }; + let server_info = + Implementation::new("codex-mcp-server", env!("CARGO_PKG_VERSION")).with_title("Codex"); // Preserve Codex's existing non-spec `serverInfo.user_agent` field. let mut server_info_value = match serde_json::to_value(&server_info) { @@ -247,17 +240,14 @@ impl MessageProcessor { obj.insert("user_agent".to_string(), json!(get_codex_user_agent())); } - let mut result_value = match serde_json::to_value(InitializeResult { - capabilities: ServerCapabilities { - tools: Some(ToolsCapability { - list_changed: Some(true), - }), - ..Default::default() - }, - instructions: None, - protocol_version: params.protocol_version.clone(), - server_info, - }) { + let capabilities = ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(); + let result = InitializeResult::new(capabilities) + .with_protocol_version(params.protocol_version.clone()) + .with_server_info(server_info); + let mut result_value = match serde_json::to_value(result) { Ok(value) => value, Err(err) => { self.outgoing @@ -345,12 +335,9 @@ impl MessageProcessor { .await } _ => { - let result = CallToolResult { - content: vec![rmcp::model::Content::text(format!("Unknown tool '{name}'"))], - structured_content: None, - is_error: Some(true), - meta: None, - }; + let result = CallToolResult::error(vec![rmcp::model::Content::text(format!( + "Unknown tool '{name}'" + ))]); self.outgoing.send_response(id, result).await; } } @@ -367,40 +354,25 @@ impl MessageProcessor { Ok(tool_cfg) => match tool_cfg.into_config(self.arg0_paths.clone()).await { Ok(cfg) => cfg, Err(e) => { - let result = CallToolResult { - content: vec![rmcp::model::Content::text(format!( - "Failed to load Codex configuration from overrides: {e}" - ))], - structured_content: None, - is_error: Some(true), - meta: None, - }; + let result = CallToolResult::error(vec![rmcp::model::Content::text( + format!("Failed to load Codex configuration from overrides: {e}"), + )]); self.outgoing.send_response(id, result).await; return; } }, Err(e) => { - let result = CallToolResult { - content: vec![rmcp::model::Content::text(format!( - "Failed to parse configuration for Codex tool: {e}" - ))], - structured_content: None, - is_error: Some(true), - meta: None, - }; + let result = CallToolResult::error(vec![rmcp::model::Content::text(format!( + "Failed to parse configuration for Codex tool: {e}" + ))]); self.outgoing.send_response(id, result).await; return; } }, None => { - let result = CallToolResult { - content: vec![rmcp::model::Content::text( - "Missing arguments for codex tool-call; the `prompt` field is required.", - )], - structured_content: None, - is_error: Some(true), - meta: None, - }; + let result = CallToolResult::error(vec![rmcp::model::Content::text( + "Missing arguments for codex tool-call; the `prompt` field is required.", + )]); self.outgoing.send_response(id, result).await; return; } @@ -441,14 +413,9 @@ impl MessageProcessor { Ok(params) => params, Err(e) => { tracing::error!("Failed to parse Codex tool call reply parameters: {e}"); - let result = CallToolResult { - content: vec![rmcp::model::Content::text(format!( - "Failed to parse configuration for Codex tool: {e}" - ))], - structured_content: None, - is_error: Some(true), - meta: None, - }; + let result = CallToolResult::error(vec![rmcp::model::Content::text(format!( + "Failed to parse configuration for Codex tool: {e}" + ))]); self.outgoing.send_response(request_id, result).await; return; } @@ -457,14 +424,9 @@ impl MessageProcessor { tracing::error!( "Missing arguments for codex-reply tool-call; the `thread_id` and `prompt` fields are required." ); - let result = CallToolResult { - content: vec![rmcp::model::Content::text( - "Missing arguments for codex-reply tool-call; the `thread_id` and `prompt` fields are required.", - )], - structured_content: None, - is_error: Some(true), - meta: None, - }; + let result = CallToolResult::error(vec![rmcp::model::Content::text( + "Missing arguments for codex-reply tool-call; the `thread_id` and `prompt` fields are required.", + )]); self.outgoing.send_response(request_id, result).await; return; } @@ -474,14 +436,9 @@ impl MessageProcessor { Ok(id) => id, Err(e) => { tracing::error!("Failed to parse thread_id: {e}"); - let result = CallToolResult { - content: vec![rmcp::model::Content::text(format!( - "Failed to parse thread_id: {e}" - ))], - structured_content: None, - is_error: Some(true), - meta: None, - }; + let result = CallToolResult::error(vec![rmcp::model::Content::text(format!( + "Failed to parse thread_id: {e}" + ))]); self.outgoing.send_response(request_id, result).await; return; } @@ -582,6 +539,7 @@ impl MessageProcessor { .submit_with_id(Submission { id: request_id_string, op: codex_protocol::protocol::Op::Interrupt, + client_user_message_id: None, trace: None, }) .await diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index b2882643ce..e6ecdd441b 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -169,7 +169,7 @@ impl From for OutgoingJsonRpcMessage { } Error(OutgoingError { id, error }) => JsonRpcMessage::Error(JsonRpcError { jsonrpc: JsonRpcVersion2_0, - id, + id: Some(id), error, }), } diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs index f50f25f49f..007d2c11cc 100644 --- a/codex-rs/mcp-server/tests/common/mcp_process.rs +++ b/codex-rs/mcp-server/tests/common/mcp_process.rs @@ -114,31 +114,18 @@ impl McpProcess { pub async fn initialize(&mut self) -> anyhow::Result<()> { let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); - let params = InitializeRequestParams { - meta: None, - capabilities: ClientCapabilities { - elicitation: Some(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: None, - }), - url: None, - }), - experimental: None, - extensions: None, - roots: None, - sampling: None, - tasks: None, - }, - client_info: Implementation { - name: "elicitation test".into(), - title: Some("Elicitation Test".into()), - version: "0.0.0".into(), - description: None, - icons: None, - website_url: None, - }, - protocol_version: ProtocolVersion::V_2025_03_26, - }; + let mut capabilities = ClientCapabilities::default(); + capabilities.elicitation = Some(ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: None, + }), + url: None, + }); + let params = InitializeRequestParams::new( + capabilities, + Implementation::new("elicitation test", "0.0.0").with_title("Elicitation Test"), + ) + .with_protocol_version(ProtocolVersion::V_2025_03_26); let params_value = serde_json::to_value(params)?; self.send_jsonrpc_message(JsonRpcMessage::Request(JsonRpcRequest { @@ -203,15 +190,12 @@ impl McpProcess { &mut self, params: CodexToolCallParam, ) -> anyhow::Result { - let codex_tool_call_params = CallToolRequestParams { - meta: None, - name: "codex".into(), - arguments: Some(match serde_json::to_value(params)? { + let codex_tool_call_params = CallToolRequestParams::new("codex").with_arguments( + match serde_json::to_value(params)? { serde_json::Value::Object(map) => map, _ => unreachable!("params serialize to object"), - }), - task: None, - }; + }, + ); self.send_request( "tools/call", Some(serde_json::to_value(codex_tool_call_params)?), diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs index 1844ab3b52..21b20759b1 100644 --- a/codex-rs/memories/write/src/phase1.rs +++ b/codex-rs/memories/write/src/phase1.rs @@ -206,7 +206,7 @@ async fn run_jobs( claimed_candidates: Vec, stage_one_context: StageOneRequestContext, ) -> Vec { - futures::stream::iter(claimed_candidates.into_iter()) + futures::stream::iter(claimed_candidates) .map(|claim| { let context = Arc::clone(&context); let config = Arc::clone(&config); diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 7bf2fe159d..2aeda32a55 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -153,7 +153,6 @@ impl MemoryStartupContext { StageOneRequestContext { model_info, - turn_metadata_header, session_telemetry: self .session_telemetry .clone() @@ -161,6 +160,7 @@ impl MemoryStartupContext { reasoning_effort: Some(reasoning_effort), reasoning_summary, service_tier: config_snapshot.service_tier, + turn_metadata_header, } } diff --git a/codex-rs/memories/write/src/start.rs b/codex-rs/memories/write/src/start.rs index 007f5f8bbc..809bf775b1 100644 --- a/codex-rs/memories/write/src/start.rs +++ b/codex-rs/memories/write/src/start.rs @@ -50,6 +50,10 @@ pub fn start_memories_startup_task( tokio::spawn(async move { let root = memory_root(&config.codex_home); + if let Err(err) = tokio::fs::create_dir_all(&root).await { + warn!("failed creating memories root: {err}"); + return; + } if let Err(err) = seed_extension_instructions(&root).await { warn!("failed seeding memory extension instructions: {err}"); } diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index 4af8560e67..bd0a9904e6 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -26,6 +26,21 @@ use tempfile::TempDir; use tokio::time::Duration; use tokio::time::Instant; +#[tokio::test] +async fn memories_startup_creates_memory_root() -> anyhow::Result<()> { + let server = start_mock_server().await; + let home = Arc::new(TempDir::new()?); + let memory_root = home.path().join("memories"); + let test = build_test_codex(&server, home).await?; + + assert!(!memory_root.exists()); + trigger_memories_startup(&test).await; + wait_for_dir(&memory_root).await?; + + shutdown_test_codex(&test).await?; + Ok(()) +} + #[tokio::test] async fn memories_startup_phase2_tracks_workspace_diff_across_runs() -> anyhow::Result<()> { let server = start_mock_server().await; @@ -237,11 +252,13 @@ async fn memories_startup_phase2_prunes_old_extension_resources_without_stage1_i } #[tokio::test] -async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Result<()> { +async fn memories_startup_phase1_uses_live_thread_service_tier_and_detached_metadata() +-> anyhow::Result<()> { let server = start_mock_server().await; let home = Arc::new(TempDir::new()?); let test = build_test_codex(&server, home).await?; assert_eq!(test.config.service_tier, None); + reset_git_repository(&test.config.cwd).await?; core_test_support::submit_thread_settings( &test.codex, @@ -279,6 +296,35 @@ async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Resu Some(ServiceTier::Fast.request_value().to_string()) ); + let stage_one = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-phase1"), + ev_assistant_message("msg-phase1", "phase1 complete"), + ev_completed("resp-phase1"), + ]), + ) + .await; + context + .stream_stage_one_prompt( + &test.config, + &codex_core::Prompt::default(), + &request_context, + ) + .await?; + let request = wait_for_single_request(&stage_one).await; + let metadata_header = request + .header("x-codex-turn-metadata") + .expect("detached memory request should include workspace metadata"); + let metadata: serde_json::Value = + serde_json::from_str(&metadata_header).expect("turn metadata json"); + assert_eq!(metadata["request_kind"].as_str(), Some("memory")); + assert!(metadata.get("session_id").is_none()); + assert!(metadata.get("thread_id").is_none()); + assert!(metadata.get("turn_id").is_none()); + assert!(metadata.get("window_id").is_none()); + assert!(metadata.get("workspaces").is_some()); + shutdown_test_codex(&test).await?; Ok(()) } @@ -377,6 +423,21 @@ async fn wait_for_file_removed(path: &Path) -> anyhow::Result<()> { } } +async fn wait_for_dir(path: &Path) -> anyhow::Result<()> { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if tokio::fs::try_exists(path).await? && path.is_dir() { + return Ok(()); + } + assert!( + Instant::now() < deadline, + "timed out waiting for {} to be created", + path.display() + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + async fn wait_for_request(mock: &ResponseMock, expected_count: usize) -> Vec { let deadline = Instant::now() + Duration::from_secs(10); loop { diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index 65f71851d6..c20a51a3aa 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -37,6 +37,7 @@ pub const OPENAI_PROVIDER_ID: &str = "openai"; pub const CHATGPT_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; const AMAZON_BEDROCK_PROVIDER_NAME: &str = "Amazon Bedrock"; pub const AMAZON_BEDROCK_PROVIDER_ID: &str = "amazon-bedrock"; +pub const AMAZON_BEDROCK_GPT_5_5_MODEL_ID: &str = "openai.gpt-5.5"; pub const AMAZON_BEDROCK_GPT_5_4_MODEL_ID: &str = "openai.gpt-5.4"; pub const AMAZON_BEDROCK_DEFAULT_BASE_URL: &str = "https://bedrock-mantle.us-east-1.api.aws/openai/v1"; diff --git a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs index 8fec7f835e..97a817fd21 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs @@ -1,149 +1,46 @@ use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_4_MODEL_ID; -use codex_models_manager::model_info::BASE_INSTRUCTIONS; -use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::ServiceTier; -use codex_protocol::config_types::Verbosity; -use codex_protocol::openai_models::ApplyPatchToolType; -use codex_protocol::openai_models::ConfigShellToolType; -use codex_protocol::openai_models::InputModality; +use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_5_MODEL_ID; +use codex_models_manager::bundled_models_response; use codex_protocol::openai_models::ModelInfo; -use codex_protocol::openai_models::ModelServiceTier; -use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; -use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::openai_models::ReasoningEffortPreset; -use codex_protocol::openai_models::SPEED_TIER_FAST; -use codex_protocol::openai_models::TruncationPolicyConfig; -use codex_protocol::openai_models::WebSearchToolType; -const GPT_OSS_CONTEXT_WINDOW: i64 = 128_000; -const GPT_5_4_CONTEXT_WINDOW: i64 = 272_000; -const GPT_5_4_MAX_CONTEXT_WINDOW: i64 = 1_000_000; +const GPT_5_BEDROCK_CONTEXT_WINDOW: i64 = 272_000; +const GPT_5_5_OPENAI_MODEL_ID: &str = "gpt-5.5"; +const GPT_5_4_OPENAI_MODEL_ID: &str = "gpt-5.4"; pub(crate) fn static_model_catalog() -> ModelsResponse { ModelsResponse { models: vec![ - gpt_5_4_cmb_bedrock_model(/*priority*/ 0), - bedrock_oss_model( - "openai.gpt-oss-120b", - "GPT OSS 120B on Bedrock", + gpt_5_bedrock_model( + GPT_5_5_OPENAI_MODEL_ID, + AMAZON_BEDROCK_GPT_5_5_MODEL_ID, + /*priority*/ 0, + ), + gpt_5_bedrock_model( + GPT_5_4_OPENAI_MODEL_ID, + AMAZON_BEDROCK_GPT_5_4_MODEL_ID, /*priority*/ 1, ), - bedrock_oss_model( - "openai.gpt-oss-20b", - "GPT OSS 20B on Bedrock", - /*priority*/ 2, - ), ], } } -fn gpt_5_4_cmb_bedrock_model(priority: i32) -> ModelInfo { - ModelInfo { - slug: AMAZON_BEDROCK_GPT_5_4_MODEL_ID.to_string(), - display_name: "gpt-5.4".to_string(), - description: Some("Strong model for everyday coding.".to_string()), - default_reasoning_level: Some(ReasoningEffort::Medium), - supported_reasoning_levels: gpt_5_4_cmb_reasoning_levels(), - shell_type: ConfigShellToolType::ShellCommand, - visibility: ModelVisibility::List, - supported_in_api: true, - priority, - additional_speed_tiers: Vec::new(), - service_tiers: vec![ModelServiceTier { - id: ServiceTier::Fast.request_value().to_string(), - name: SPEED_TIER_FAST.to_string(), - description: "Fastest inference with increased plan usage".to_string(), - }], - default_service_tier: None, - availability_nux: None, - upgrade: None, - base_instructions: BASE_INSTRUCTIONS.to_string(), - model_messages: None, - supports_reasoning_summaries: true, - default_reasoning_summary: ReasoningSummary::None, - support_verbosity: true, - default_verbosity: Some(Verbosity::Medium), - apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), - web_search_tool_type: WebSearchToolType::TextAndImage, - truncation_policy: TruncationPolicyConfig::tokens(/*limit*/ 10_000), - supports_parallel_tool_calls: true, - supports_image_detail_original: true, - context_window: Some(GPT_5_4_CONTEXT_WINDOW), - max_context_window: Some(GPT_5_4_MAX_CONTEXT_WINDOW), - auto_compact_token_limit: None, - effective_context_window_percent: 95, - experimental_supported_tools: Vec::new(), - input_modalities: vec![InputModality::Text, InputModality::Image], - used_fallback_model_metadata: false, - supports_search_tool: true, - } +fn gpt_5_bedrock_model(openai_slug: &str, bedrock_slug: &str, priority: i32) -> ModelInfo { + let mut model = bundled_openai_model(openai_slug); + model.slug = bedrock_slug.to_string(); + model.priority = priority; + model.context_window = Some(GPT_5_BEDROCK_CONTEXT_WINDOW); + model.max_context_window = Some(GPT_5_BEDROCK_CONTEXT_WINDOW); + model } -fn bedrock_oss_model(slug: &str, display_name: &str, priority: i32) -> ModelInfo { - ModelInfo { - slug: slug.to_string(), - display_name: display_name.to_string(), - description: Some(display_name.to_string()), - default_reasoning_level: Some(ReasoningEffort::Medium), - supported_reasoning_levels: vec![ - reasoning_effort_preset(ReasoningEffort::Low), - reasoning_effort_preset(ReasoningEffort::Medium), - reasoning_effort_preset(ReasoningEffort::High), - ], - shell_type: ConfigShellToolType::ShellCommand, - visibility: ModelVisibility::List, - supported_in_api: true, - priority, - additional_speed_tiers: Vec::new(), - service_tiers: Vec::new(), - default_service_tier: None, - availability_nux: None, - upgrade: None, - base_instructions: BASE_INSTRUCTIONS.to_string(), - model_messages: None, - supports_reasoning_summaries: true, - default_reasoning_summary: ReasoningSummary::None, - support_verbosity: false, - default_verbosity: None, - apply_patch_tool_type: None, - web_search_tool_type: WebSearchToolType::Text, - truncation_policy: TruncationPolicyConfig::tokens(/*limit*/ 10_000), - supports_parallel_tool_calls: true, - supports_image_detail_original: false, - context_window: Some(GPT_OSS_CONTEXT_WINDOW), - max_context_window: Some(GPT_OSS_CONTEXT_WINDOW), - auto_compact_token_limit: None, - effective_context_window_percent: 95, - experimental_supported_tools: Vec::new(), - input_modalities: vec![InputModality::Text], - used_fallback_model_metadata: false, - supports_search_tool: false, - } -} - -fn gpt_5_4_cmb_reasoning_levels() -> Vec { - vec![ - reasoning_effort_preset(ReasoningEffort::Minimal), - reasoning_effort_preset(ReasoningEffort::Low), - reasoning_effort_preset(ReasoningEffort::Medium), - reasoning_effort_preset(ReasoningEffort::High), - ] -} - -fn reasoning_effort_preset(effort: ReasoningEffort) -> ReasoningEffortPreset { - ReasoningEffortPreset { - effort, - description: match effort { - ReasoningEffort::None => "No reasoning", - ReasoningEffort::Minimal => "Minimal reasoning", - ReasoningEffort::Low => "Fast responses with lighter reasoning", - ReasoningEffort::Medium => "Balances speed and reasoning depth for everyday tasks", - ReasoningEffort::High => "Greater reasoning depth for complex problems", - ReasoningEffort::XHigh => "Extra high reasoning depth for complex problems", - } - .to_string(), - } +fn bundled_openai_model(slug: &str) -> ModelInfo { + bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")) + .models + .into_iter() + .find(|model| model.slug == slug) + .unwrap_or_else(|| panic!("bundled models.json should include {slug}")) } #[cfg(test)] @@ -156,24 +53,38 @@ mod tests { fn catalog_uses_mantle_model_ids_as_slugs() { let catalog = static_model_catalog(); - assert_eq!(catalog.models.len(), 3); - assert_eq!(catalog.models[0].slug, AMAZON_BEDROCK_GPT_5_4_MODEL_ID); - assert_eq!(catalog.models[1].slug, "openai.gpt-oss-120b"); - assert_eq!(catalog.models[2].slug, "openai.gpt-oss-20b"); + assert_eq!(catalog.models.len(), 2); + assert_eq!(catalog.models[0].slug, AMAZON_BEDROCK_GPT_5_5_MODEL_ID); + assert_eq!(catalog.models[1].slug, AMAZON_BEDROCK_GPT_5_4_MODEL_ID); } #[test] - fn gpt_5_4_cmb_advertises_only_bedrock_supported_reasoning_levels() { + fn gpt_5_bedrock_models_use_bedrock_context_window() { let catalog = static_model_catalog(); - let cmb_model = catalog + let gpt_5_5 = catalog + .models + .iter() + .find(|model| model.slug == AMAZON_BEDROCK_GPT_5_5_MODEL_ID) + .expect("Bedrock catalog should include GPT-5.5"); + let gpt_5_4 = catalog .models .iter() .find(|model| model.slug == AMAZON_BEDROCK_GPT_5_4_MODEL_ID) - .expect("Bedrock catalog should include GPT-5.4 CMB"); + .expect("Bedrock catalog should include GPT-5.4"); assert_eq!( - cmb_model.supported_reasoning_levels, - gpt_5_4_cmb_reasoning_levels() + (gpt_5_5.context_window, gpt_5_5.max_context_window), + ( + Some(GPT_5_BEDROCK_CONTEXT_WINDOW), + Some(GPT_5_BEDROCK_CONTEXT_WINDOW) + ) + ); + assert_eq!( + (gpt_5_4.context_window, gpt_5_4.max_context_window), + ( + Some(GPT_5_BEDROCK_CONTEXT_WINDOW), + Some(GPT_5_BEDROCK_CONTEXT_WINDOW) + ) ); } } diff --git a/codex-rs/model-provider/src/amazon_bedrock/mantle.rs b/codex-rs/model-provider/src/amazon_bedrock/mantle.rs index e5b497d355..7881845e45 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mantle.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mantle.rs @@ -7,11 +7,10 @@ use super::auth::BedrockAuthMethod; use super::auth::resolve_auth_method; const BEDROCK_MANTLE_SERVICE_NAME: &str = "bedrock-mantle"; -const BEDROCK_MANTLE_SUPPORTED_REGIONS: [&str; 13] = [ +const BEDROCK_MANTLE_SUPPORTED_REGIONS: [&str; 12] = [ "us-east-2", "us-east-1", "us-west-2", - "us-gov-west-1", "ap-southeast-3", "ap-south-1", "ap-northeast-1", @@ -73,10 +72,6 @@ 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/model-provider/src/amazon_bedrock/mod.rs b/codex-rs/model-provider/src/amazon_bedrock/mod.rs index 3940f73fcd..4d6a9afb3c 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mod.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mod.rs @@ -57,7 +57,7 @@ impl ModelProvider for AmazonBedrockModelProvider { fn capabilities(&self) -> ProviderCapabilities { ProviderCapabilities { - namespace_tools: false, + namespace_tools: true, image_generation: false, web_search: false, } @@ -131,7 +131,7 @@ mod tests { } #[test] - fn capabilities_disable_unsupported_launch_features() { + fn capabilities_disable_unsupported_hosted_tools() { let provider = AmazonBedrockModelProvider::new( ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None), ); @@ -139,7 +139,7 @@ mod tests { assert_eq!( provider.capabilities(), ProviderCapabilities { - namespace_tools: false, + namespace_tools: true, image_generation: false, web_search: false, } diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 1ef7c22962..f5e5ab2ff5 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -507,14 +507,7 @@ mod tests { .map(|model| model.slug.as_str()) .collect::>(); - assert_eq!( - model_ids, - vec![ - "openai.gpt-5.4", - "openai.gpt-oss-120b", - "openai.gpt-oss-20b" - ] - ); + assert_eq!(model_ids, vec!["openai.gpt-5.5", "openai.gpt-5.4"]); let default_model = manager .list_models(RefreshStrategy::Online) @@ -523,7 +516,7 @@ mod tests { .find(|preset| preset.is_default) .expect("Bedrock catalog should have a default model"); - assert_eq!(default_model.model, "openai.gpt-5.4"); + assert_eq!(default_model.model, "openai.gpt-5.5"); } #[tokio::test] diff --git a/codex-rs/models-manager/src/manager.rs b/codex-rs/models-manager/src/manager.rs index af510c8d73..ac4044ac20 100644 --- a/codex-rs/models-manager/src/manager.rs +++ b/codex-rs/models-manager/src/manager.rs @@ -107,7 +107,7 @@ pub trait ModelsManager: fmt::Debug + Send + Sync { /// Build picker-ready presets from the active catalog snapshot. fn build_available_models(&self, mut remote_models: Vec) -> Vec { - remote_models.sort_by(|a, b| a.priority.cmp(&b.priority)); + remote_models.sort_by_key(|model| model.priority); let mut presets: Vec = remote_models.into_iter().map(Into::into).collect(); let uses_codex_backend = self diff --git a/codex-rs/models-manager/src/test_support.rs b/codex-rs/models-manager/src/test_support.rs index aff2838907..7397231689 100644 --- a/codex-rs/models-manager/src/test_support.rs +++ b/codex-rs/models-manager/src/test_support.rs @@ -14,7 +14,7 @@ pub fn get_model_offline_for_tests(model: Option<&str>) -> String { return model.to_string(); } let mut response = bundled_models_response().unwrap_or_default(); - response.models.sort_by(|a, b| a.priority.cmp(&b.priority)); + response.models.sort_by_key(|model| model.priority); let presets: Vec = response.models.into_iter().map(Into::into).collect(); presets .iter() diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 817545c87d..d2093ed3f7 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -28,6 +28,10 @@ pub const TURN_NETWORK_PROXY_METRIC: &str = "codex.turn.network_proxy"; pub const TURN_MEMORY_METRIC: &str = "codex.turn.memory"; pub const TURN_TOOL_CALL_METRIC: &str = "codex.turn.tool.call"; pub const TURN_TOKEN_USAGE_METRIC: &str = "codex.turn.token_usage"; +pub const GUARDIAN_REVIEW_COUNT_METRIC: &str = "codex.guardian.review"; +pub const GUARDIAN_REVIEW_DURATION_METRIC: &str = "codex.guardian.review.duration_ms"; +pub const GUARDIAN_REVIEW_TTFT_DURATION_METRIC: &str = "codex.guardian.review.ttft.duration_ms"; +pub const GUARDIAN_REVIEW_TOKEN_USAGE_METRIC: &str = "codex.guardian.review.token_usage"; pub const GOAL_CREATED_METRIC: &str = "codex.goal.created"; pub const GOAL_RESUMED_METRIC: &str = "codex.goal.resumed"; pub const GOAL_COMPLETED_METRIC: &str = "codex.goal.completed"; diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 7d12579564..ee61dc3949 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -56,6 +56,9 @@ pub enum TurnItem { #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] pub struct UserMessageItem { pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub client_id: Option, pub content: Vec, } @@ -237,6 +240,7 @@ impl UserMessageItem { pub fn new(content: &[UserInput]) -> Self { Self { id: uuid::Uuid::new_v4().to_string(), + client_id: None, content: content.to_vec(), } } @@ -245,6 +249,7 @@ impl UserMessageItem { // Legacy user-message events flatten only text inputs into `message` and // rebase text element ranges onto that concatenated text. EventMsg::UserMessage(UserMessageEvent { + client_id: self.client_id.clone(), message: self.message(), images: Some(self.image_urls()), image_details: self.image_details(), @@ -272,6 +277,7 @@ impl UserMessageItem { if let UserInput::Text { text, text_elements, + .. } = input { // Text element ranges are relative to each text chunk; offset them so they align diff --git a/codex-rs/protocol/src/mcp.rs b/codex-rs/protocol/src/mcp.rs index f6e69743b9..a1916d424c 100644 --- a/codex-rs/protocol/src/mcp.rs +++ b/codex-rs/protocol/src/mcp.rs @@ -26,6 +26,18 @@ impl std::fmt::Display for RequestId { } } +/// Presentation metadata advertised by an initialized MCP server. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct McpServerInfo { + pub name: String, + pub title: Option, + pub version: String, + pub description: Option, + pub icons: Option>, + pub website_url: Option, +} + /// Definition for a tool the client can call. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 6112ea8a6c..cbf8895ddb 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -725,6 +725,8 @@ pub enum ContentItem { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "lowercase")] pub enum ImageDetail { + Auto, + Low, High, Original, } @@ -1077,7 +1079,7 @@ pub fn local_image_content_items_with_label_number( ) -> Vec { let mode = match detail { ImageDetail::Original => PromptImageMode::Original, - ImageDetail::High => PromptImageMode::ResizeToFit, + ImageDetail::Auto | ImageDetail::Low | ImageDetail::High => PromptImageMode::ResizeToFit, }; match load_for_prompt_bytes(path, file_bytes, mode) { @@ -1235,23 +1237,17 @@ impl From> for ResponseInputItem { .into_iter() .flat_map(|c| match c { UserInput::Text { text, .. } => vec![ContentItem::InputText { text }], - UserInput::Image { image_url, detail } => { + UserInput::Image { + image_url, detail, .. + } => { image_index += 1; let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL); - vec![ - ContentItem::InputText { - text: image_open_tag_text(), - }, - ContentItem::InputImage { - image_url, - detail: Some(detail), - }, - ContentItem::InputText { - text: image_close_tag_text(), - }, - ] + vec![ContentItem::InputImage { + image_url, + detail: Some(detail), + }] } - UserInput::LocalImage { path, detail } => { + UserInput::LocalImage { path, detail, .. } => { image_index += 1; let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL); match std::fs::read(&path) { @@ -1607,6 +1603,8 @@ fn convert_mcp_content_to_items( .and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY)) .and_then(serde_json::Value::as_str) .and_then(|detail| match detail { + "auto" => Some(ImageDetail::Auto), + "low" => Some(ImageDetail::Low), "high" => Some(ImageDetail::High), "original" => Some(ImageDetail::Original), _ => None, @@ -1682,6 +1680,36 @@ mod tests { ); } + #[test] + fn image_detail_roundtrips_all_wire_values() -> Result<()> { + assert_eq!( + serde_json::from_str::("\"auto\"")?, + ImageDetail::Auto + ); + assert_eq!( + serde_json::from_str::("\"low\"")?, + ImageDetail::Low + ); + assert_eq!(serde_json::to_string(&ImageDetail::Auto)?, "\"auto\""); + assert_eq!(serde_json::to_string(&ImageDetail::Low)?, "\"low\""); + + let content_item: ContentItem = serde_json::from_value(serde_json::json!({ + "type": "input_image", + "image_url": "data:image/png;base64,abc", + "detail": "auto", + }))?; + + assert_eq!( + content_item, + ContentItem::InputImage { + image_url: "data:image/png;base64,abc".to_string(), + detail: Some(ImageDetail::Auto), + } + ); + + Ok(()) + } + #[test] fn sandbox_permissions_helpers_match_documented_semantics() { let cases = [ @@ -2622,7 +2650,7 @@ mod tests { } #[test] - fn wraps_image_user_input_with_tags() -> Result<()> { + fn serializes_image_user_input_without_tags() -> Result<()> { let image_url = "data:image/png;base64,abc".to_string(); let item = ResponseInputItem::from(vec![UserInput::Image { @@ -2632,18 +2660,10 @@ mod tests { match item { ResponseInputItem::Message { content, .. } => { - let expected = vec![ - ContentItem::InputText { - text: image_open_tag_text(), - }, - ContentItem::InputImage { - image_url, - detail: Some(DEFAULT_IMAGE_DETAIL), - }, - ContentItem::InputText { - text: image_close_tag_text(), - }, - ]; + let expected = vec![ContentItem::InputImage { + image_url, + detail: Some(DEFAULT_IMAGE_DETAIL), + }]; assert_eq!(content, expected); } other => panic!("expected message response but got {other:?}"), @@ -2664,7 +2684,7 @@ mod tests { match item { ResponseInputItem::Message { content, .. } => { assert_eq!( - content.get(1), + content.first(), Some(&ContentItem::InputImage { image_url, detail: Some(ImageDetail::Original), @@ -2862,35 +2882,23 @@ mod tests { ResponseInputItem::Message { content, .. } => { assert_eq!( content.first(), - Some(&ContentItem::InputText { - text: image_open_tag_text(), - }) - ); - assert_eq!( - content.get(1), Some(&ContentItem::InputImage { image_url, detail: Some(DEFAULT_IMAGE_DETAIL), }) ); assert_eq!( - content.get(2), - Some(&ContentItem::InputText { - text: image_close_tag_text(), - }) - ); - assert_eq!( - content.get(3), + content.get(1), Some(&ContentItem::InputText { text: local_image_open_tag_text(/*label_number*/ 2), }) ); assert!(matches!( - content.get(4), + content.get(2), Some(ContentItem::InputImage { .. }) )); assert_eq!( - content.get(5), + content.get(3), Some(&ContentItem::InputText { text: image_close_tag_text(), }) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a675362b62..3c0b5cb462 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -129,6 +129,9 @@ pub struct Submission { pub id: String, /// Payload pub op: Op, + /// Client-provided id for the user message represented by `Op::UserInput`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_user_message_id: Option, /// Optional W3C trace carrier propagated across async submission handoffs. #[serde(default, skip_serializing_if = "Option::is_none")] pub trace: Option, @@ -2164,6 +2167,8 @@ pub struct AgentMessageEvent { #[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)] pub struct UserMessageEvent { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, pub message: String, /// Image URLs sourced from `UserInput::Image`. These are safe /// to replay in legacy UI history events and correspond to images sent to @@ -2767,6 +2772,10 @@ pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub turn_id: Option, pub cwd: PathBuf, + /// Effective workspace roots used to materialize symbolic + /// `:workspace_roots` filesystem permissions in `permission_profile`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_roots: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_date: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -5088,6 +5097,7 @@ mod tests { #[test] fn user_message_event_serializes_empty_metadata_vectors() -> Result<()> { let event = UserMessageEvent { + client_id: None, message: "hello".to_string(), images: None, local_images: Vec::new(), @@ -5133,7 +5143,7 @@ mod tests { #[test] fn user_message_item_legacy_event_preserves_image_details() { let local_path = PathBuf::from("/tmp/local.png"); - let item = UserMessageItem::new(&[ + let mut item = UserMessageItem::new(&[ crate::user_input::UserInput::Image { image_url: "https://example.com/first.png".to_string(), detail: Some(ImageDetail::Original), @@ -5147,6 +5157,7 @@ mod tests { detail: Some(ImageDetail::Original), }, ]); + item.client_id = Some("client-message-1".to_string()); let EventMsg::UserMessage(event) = item.as_legacy_event() else { panic!("expected user message event"); @@ -5159,6 +5170,7 @@ mod tests { "https://example.com/second.png".to_string(), ]) ); + assert_eq!(event.client_id, Some("client-message-1".to_string())); assert_eq!(event.image_details, vec![Some(ImageDetail::Original)]); assert_eq!(event.local_images, vec![local_path]); assert_eq!(event.local_image_details, vec![Some(ImageDetail::Original)]); @@ -5204,6 +5216,7 @@ mod tests { let item = TurnContextItem { turn_id: None, cwd: test_path_buf("/tmp"), + workspace_roots: None, current_date: None, timezone: None, approval_policy: AskForApproval::Never, diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 0efd8a4178..63c6d74e4a 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -11,6 +11,7 @@ workspace = true anyhow = "1" axum = { workspace = true, default-features = false, features = [ "http1", + "json", "tokio", ] } base64 = { workspace = true } @@ -26,10 +27,10 @@ bytes = { workspace = true } futures = { workspace = true, default-features = false, features = ["std"] } keyring = { workspace = true, features = ["crypto-rust"] } oauth2 = "5" -reqwest = { version = "0.12", default-features = false, features = [ +reqwest = { version = "0.13", default-features = false, features = [ "json", "stream", - "rustls-tls", + "rustls", ] } rmcp = { workspace = true, default-features = false, features = [ "auth", diff --git a/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs b/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs index 5bf32124d0..6911afb211 100644 --- a/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs +++ b/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs @@ -79,13 +79,12 @@ struct EchoArgs { impl ServerHandler for TestToolServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() .build(), - ..ServerInfo::default() - } + ) } fn list_tools( @@ -130,12 +129,9 @@ impl ServerHandler for TestToolServer { "env": env_snapshot.get(env_name), }); - Ok(CallToolResult { - content: Vec::new(), - structured_content: Some(structured_content), - is_error: Some(false), - meta: None, - }) + let mut result = CallToolResult::success(Vec::new()); + result.structured_content = Some(structured_content); + Ok(result) } other => Err(McpError::invalid_params( format!("unknown tool: {other}"), 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 50657ab182..4407a27eeb 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -407,11 +407,8 @@ impl ServerHandler for TestToolServer { JsonObject::new(), )])); - ServerInfo { - instructions: Some("Use these tools to exercise the rmcp test server.".to_string()), - capabilities, - ..ServerInfo::default() - } + ServerInfo::new(capabilities) + .with_instructions("Use these tools to exercise the rmcp test server.") } fn list_tools( @@ -462,14 +459,14 @@ impl ServerHandler for TestToolServer { _context: rmcp::service::RequestContext, ) -> Result { if uri == MEMO_URI { - Ok(ReadResourceResult { - contents: vec![ResourceContents::TextResourceContents { + Ok(ReadResourceResult::new(vec![ + ResourceContents::TextResourceContents { uri, mime_type: Some("text/plain".to_string()), text: Self::memo_text().to_string(), meta: None, - }], - }) + }, + ])) } else { Err(McpError::resource_not_found( "resource_not_found", @@ -484,22 +481,14 @@ impl ServerHandler for TestToolServer { context: rmcp::service::RequestContext, ) -> Result { match request.name.as_ref() { - "sandbox_meta" => Ok(CallToolResult { - content: Vec::new(), - structured_content: Some(serde_json::Value::Object(context.meta.0)), - is_error: Some(false), - meta: None, - }), + "sandbox_meta" => Ok(Self::structured_result(serde_json::Value::Object( + context.meta.0, + ))), "cwd" => { let cwd = std::env::current_dir() .map(|path| path.to_string_lossy().into_owned()) .map_err(|err| McpError::internal_error(err.to_string(), None))?; - Ok(CallToolResult { - content: Vec::new(), - structured_content: Some(json!({ "cwd": cwd })), - is_error: Some(false), - meta: None, - }) + Ok(Self::structured_result(json!({ "cwd": cwd }))) } "echo" | "echo-tool" => { let args: EchoArgs = match request.arguments { @@ -522,12 +511,7 @@ impl ServerHandler for TestToolServer { "env": env_snapshot.get(env_name), }); - Ok(CallToolResult { - content: Vec::new(), - structured_content: Some(structured_content), - is_error: Some(false), - meta: None, - }) + Ok(Self::structured_result(structured_content)) } "image" => { // Read a data URL (e.g. data:image/png;base64,AAA...) from env and convert to @@ -677,12 +661,13 @@ impl TestToolServer { sleep(Duration::from_millis(delay)).await; } - Ok(CallToolResult { - content: Vec::new(), - structured_content: Some(json!({ "result": "ok" })), - is_error: Some(false), - meta: None, - }) + Ok(Self::structured_result(json!({ "result": "ok" }))) + } + + fn structured_result(value: serde_json::Value) -> CallToolResult { + let mut result = CallToolResult::success(Vec::new()); + result.structured_content = Some(value); + result } } diff --git a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs index d1c22f430c..2384d39473 100644 --- a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs @@ -11,12 +11,14 @@ use axum::body::Body; use axum::extract::Json; use axum::extract::State; use axum::http::HeaderMap; +use axum::http::HeaderValue; use axum::http::Method; use axum::http::Request; use axum::http::StatusCode; use axum::http::header::AUTHORIZATION; use axum::http::header::CONTENT_TYPE; use axum::http::header::HOST; +use axum::http::header::WWW_AUTHENTICATE; use axum::middleware; use axum::middleware::Next; use axum::response::Response; @@ -72,12 +74,17 @@ struct SessionFailureState { struct ArmedFailure { status: StatusCode, remaining: usize, + /// Raw `WWW-Authenticate` challenge header field values returned with the failure. + www_authenticate_headers: Vec, } #[derive(Debug, Deserialize)] struct ArmSessionPostFailureRequest { status: u16, remaining: usize, + /// Raw `WWW-Authenticate` challenge header field values to add to the failure. + #[serde(default)] + www_authenticate_headers: Vec, } #[derive(Deserialize)] @@ -174,14 +181,13 @@ async fn main() -> Result<(), Box> { impl ServerHandler for TestToolServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() .enable_resources() .build(), - ..ServerInfo::default() - } + ) } fn list_tools( @@ -232,14 +238,14 @@ impl ServerHandler for TestToolServer { _context: rmcp::service::RequestContext, ) -> Result { if uri == MEMO_URI { - Ok(ReadResourceResult { - contents: vec![ResourceContents::TextResourceContents { + Ok(ReadResourceResult::new(vec![ + ResourceContents::TextResourceContents { uri, mime_type: Some("text/plain".to_string()), text: Self::memo_text().to_string(), meta: None, - }], - }) + }, + ])) } else { Err(McpError::resource_not_found( "resource_not_found", @@ -274,12 +280,9 @@ impl ServerHandler for TestToolServer { "env": env_snapshot.get("MCP_TEST_VALUE"), }); - Ok(CallToolResult { - content: Vec::new(), - structured_content: Some(structured_content), - is_error: Some(false), - meta: None, - }) + let mut result = CallToolResult::success(Vec::new()); + result.structured_content = Some(structured_content); + Ok(result) } other => Err(McpError::invalid_params( format!("unknown tool: {other}"), @@ -405,12 +408,18 @@ async fn arm_session_post_failure( Json(request): Json, ) -> Result { let status = StatusCode::from_u16(request.status).map_err(|_| StatusCode::BAD_REQUEST)?; + let www_authenticate_headers = request + .www_authenticate_headers + .into_iter() + .map(|value| HeaderValue::from_str(&value).map_err(|_| StatusCode::BAD_REQUEST)) + .collect::, _>>()?; let armed_failure = if request.remaining == 0 { None } else { Some(ArmedFailure { status, remaining: request.remaining, + www_authenticate_headers, }) }; *state.armed_failure.lock().await = armed_failure; @@ -436,6 +445,7 @@ async fn fail_session_post_when_armed( { failure.remaining -= 1; let status = failure.status; + let www_authenticate_headers = failure.www_authenticate_headers.clone(); if failure.remaining == 0 { *armed_failure = None; } @@ -443,6 +453,11 @@ async fn fail_session_post_when_armed( "forced session failure with status {status}" ))); *response.status_mut() = status; + for www_authenticate_header in www_authenticate_headers { + response + .headers_mut() + .append(WWW_AUTHENTICATE, www_authenticate_header); + } return response; } } diff --git a/codex-rs/rmcp-client/src/http_client_adapter.rs b/codex-rs/rmcp-client/src/http_client_adapter.rs index a1e6680e60..0a5643aabf 100644 --- a/codex-rs/rmcp-client/src/http_client_adapter.rs +++ b/codex-rs/rmcp-client/src/http_client_adapter.rs @@ -7,6 +7,7 @@ //! - a local HTTP client that issues requests from the orchestrator, or //! - a remote HTTP client that forwards requests to the remote runtime +use std::collections::HashMap; use std::io; use std::sync::Arc; @@ -29,12 +30,17 @@ use reqwest::header::HeaderName; use rmcp::model::ClientJsonRpcMessage; use rmcp::model::ServerJsonRpcMessage; use rmcp::transport::streamable_http_client::AuthRequiredError; +use rmcp::transport::streamable_http_client::InsufficientScopeError; use rmcp::transport::streamable_http_client::StreamableHttpClient; use rmcp::transport::streamable_http_client::StreamableHttpError; use rmcp::transport::streamable_http_client::StreamableHttpPostResponse; use sse_stream::Sse; use sse_stream::SseStream; +mod www_authenticate; + +use self::www_authenticate::insufficient_scope_challenge; + const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream"; const JSON_MIME_TYPE: &str = "application/json"; const HEADER_SESSION_ID: &str = "Mcp-Session-Id"; @@ -80,8 +86,10 @@ impl StreamableHttpClient for StreamableHttpClientAdapter { message: ClientJsonRpcMessage, session_id: Option>, auth_token: Option, + custom_headers: HashMap, ) -> std::result::Result> { let mut headers = self.default_headers.clone(); + headers.extend(custom_headers); self.add_auth_headers(&mut headers); insert_header( &mut headers, @@ -137,9 +145,19 @@ impl StreamableHttpClient for StreamableHttpClientAdapter { && let Some(header) = response_header(&response.headers, reqwest::header::WWW_AUTHENTICATE) { - return Err(StreamableHttpError::AuthRequired(AuthRequiredError { - www_authenticate_header: header, - })); + return Err(StreamableHttpError::AuthRequired(AuthRequiredError::new( + header, + ))); + } + if response.status == StatusCode::FORBIDDEN.as_u16() + && let Some(challenge) = insufficient_scope_challenge(&response.headers) + { + return Err(StreamableHttpError::InsufficientScope( + InsufficientScopeError::new( + challenge.www_authenticate_header, + challenge.required_scope, + ), + )); } if matches!( StatusCode::from_u16(response.status).ok(), @@ -177,8 +195,10 @@ impl StreamableHttpClient for StreamableHttpClientAdapter { uri: Arc, session: Arc, auth_token: Option, + custom_headers: HashMap, ) -> std::result::Result<(), StreamableHttpError> { let mut headers = self.default_headers.clone(); + headers.extend(custom_headers); self.add_auth_headers(&mut headers); if let Some(auth_token) = auth_token { insert_header( @@ -227,11 +247,13 @@ impl StreamableHttpClient for StreamableHttpClientAdapter { session_id: Arc, last_event_id: Option, auth_token: Option, + custom_headers: HashMap, ) -> std::result::Result< BoxStream<'static, std::result::Result>, StreamableHttpError, > { let mut headers = self.default_headers.clone(); + headers.extend(custom_headers); self.add_auth_headers(&mut headers); insert_header( &mut headers, diff --git a/codex-rs/rmcp-client/src/http_client_adapter/www_authenticate.rs b/codex-rs/rmcp-client/src/http_client_adapter/www_authenticate.rs new file mode 100644 index 0000000000..3c122c3bb7 --- /dev/null +++ b/codex-rs/rmcp-client/src/http_client_adapter/www_authenticate.rs @@ -0,0 +1,233 @@ +use codex_exec_server::HttpHeader; +use reqwest::header::WWW_AUTHENTICATE; + +#[derive(Debug, PartialEq, Eq)] +pub(super) struct InsufficientScopeChallenge { + pub(super) www_authenticate_header: String, + pub(super) required_scope: Option, +} + +#[derive(Debug, PartialEq, Eq)] +struct BearerInsufficientScope { + required_scope: Option, +} + +type AuthParameter<'a> = (&'a str, Option); +type ChallengeStart<'a> = (&'a str, Option>); + +#[derive(Default)] +enum Parameter { + #[default] + Missing, + Value(String), + Invalid, +} + +#[derive(Default)] +struct BearerChallenge { + error: Parameter, + scope: Parameter, +} + +impl BearerChallenge { + fn add_parameter(&mut self, name: &str, value: Option) { + let parameter = if name.eq_ignore_ascii_case("error") { + &mut self.error + } else if name.eq_ignore_ascii_case("scope") { + &mut self.scope + } else { + return; + }; + + *parameter = match (&*parameter, value) { + (Parameter::Missing, Some(value)) => Parameter::Value(value), + (Parameter::Missing, None) | (Parameter::Value(_), _) | (Parameter::Invalid, _) => { + Parameter::Invalid + } + }; + } + + fn into_insufficient_scope(self) -> Option { + match self.error { + Parameter::Value(error) if error == "insufficient_scope" => { + Some(BearerInsufficientScope { + required_scope: match self.scope { + Parameter::Value(scope) if valid_scope(&scope) => Some(scope), + Parameter::Missing | Parameter::Value(_) | Parameter::Invalid => None, + }, + }) + } + Parameter::Missing | Parameter::Value(_) | Parameter::Invalid => None, + } + } +} + +/// Finds a Bearer insufficient-scope challenge among all `WWW-Authenticate` +/// response header field values. +pub(super) fn insufficient_scope_challenge( + headers: &[HttpHeader], +) -> Option { + headers + .iter() + .filter(|header| header.name.eq_ignore_ascii_case(WWW_AUTHENTICATE.as_str())) + .find_map(|header| { + parse_bearer_insufficient_scope(&header.value).map(|challenge| { + InsufficientScopeChallenge { + www_authenticate_header: header.value.clone(), + required_scope: challenge.required_scope, + } + }) + }) +} + +/// Parses a Bearer `WWW-Authenticate` challenge with an `insufficient_scope` +/// error and extracts its optional required scope. +/// +/// RFC 9110 section 11.2 defines challenge parameters as `auth-param` values +/// whose values are either `token` or `quoted-string`. Quoted strings use HTTP +/// syntax rather than JSON: section 5.6.4 requires recipients to replace each +/// `quoted-pair` with its escaped octet. +/// +/// RFC 6750 section 3 permits `scope` in the Bearer challenge at most once. +/// After HTTP quoted-string processing, each scope token can contain `%x21`, +/// `%x23-5B`, or `%x5D-7E`, with `%x20` separating multiple tokens. Therefore +/// returned scopes cannot contain `"` or `\`, even when those characters occur +/// in the header encoding. +/// +/// RMCP has related parsing logic, but it is private to that crate. +fn parse_bearer_insufficient_scope(header: &str) -> Option { + let segments = split_unquoted_segments(header)?; + let mut bearer_challenge: Option = None; + + for segment in segments { + if let Some((name, value)) = parse_auth_param(segment) { + if let Some(challenge) = bearer_challenge.as_mut() { + challenge.add_parameter(name, value); + } + continue; + } + + if let Some(challenge) = bearer_challenge + .take() + .and_then(BearerChallenge::into_insufficient_scope) + { + return Some(challenge); + } + + let (scheme, parameter) = parse_challenge_start(segment)?; + if scheme.eq_ignore_ascii_case("Bearer") { + let mut challenge = BearerChallenge::default(); + if let Some((name, value)) = parameter { + challenge.add_parameter(name, value); + } + bearer_challenge = Some(challenge); + } + } + + bearer_challenge.and_then(BearerChallenge::into_insufficient_scope) +} + +fn parse_challenge_start(segment: &str) -> Option> { + let segment = segment.trim(); + let parameter_start = segment.find(char::is_whitespace); + let (scheme, parameter) = match parameter_start { + Some(parameter_start) => ( + &segment[..parameter_start], + parse_auth_param(&segment[parameter_start..]), + ), + None => (segment, None), + }; + + is_http_token(scheme).then_some((scheme, parameter)) +} + +fn parse_auth_param(segment: &str) -> Option> { + let (name, value) = segment.trim().split_once('=')?; + let name = name.trim(); + is_http_token(name).then_some((name, parse_auth_param_value(value.trim()))) +} + +fn parse_auth_param_value(value: &str) -> Option { + if let Some(quoted_value) = value.strip_prefix('"') { + let quoted_value = quoted_value.strip_suffix('"')?; + let mut decoded = String::with_capacity(quoted_value.len()); + let mut characters = quoted_value.chars(); + while let Some(character) = characters.next() { + if character == '\\' { + decoded.push(characters.next()?); + } else { + decoded.push(character); + } + } + Some(decoded) + } else { + is_http_token(value).then(|| value.to_string()) + } +} + +fn split_unquoted_segments(header: &str) -> Option> { + let mut segments = Vec::new(); + let mut segment_start = 0; + let mut in_quotes = false; + let mut escaped = false; + + for (position, character) in header.char_indices() { + if escaped { + escaped = false; + continue; + } + match character { + '\\' if in_quotes => escaped = true, + '"' => in_quotes = !in_quotes, + ',' | ';' if !in_quotes => { + segments.push(&header[segment_start..position]); + segment_start = position + character.len_utf8(); + } + _ => {} + } + } + + if in_quotes || escaped { + None + } else { + segments.push(&header[segment_start..]); + Some(segments) + } +} + +fn valid_scope(scope: &str) -> bool { + scope.split(' ').all(|token| { + !token.is_empty() + && token + .bytes() + .all(|byte| matches!(byte, b'!' | b'#'..=b'[' | b']'..=b'~')) + }) +} + +fn is_http_token(value: &str) -> bool { + !value.is_empty() + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) +} + +#[cfg(test)] +#[path = "www_authenticate_tests.rs"] +mod tests; diff --git a/codex-rs/rmcp-client/src/http_client_adapter/www_authenticate_tests.rs b/codex-rs/rmcp-client/src/http_client_adapter/www_authenticate_tests.rs new file mode 100644 index 0000000000..6696655d88 --- /dev/null +++ b/codex-rs/rmcp-client/src/http_client_adapter/www_authenticate_tests.rs @@ -0,0 +1,124 @@ +use codex_exec_server::HttpHeader; +use pretty_assertions::assert_eq; + +use super::BearerInsufficientScope; +use super::InsufficientScopeChallenge; +use super::insufficient_scope_challenge; +use super::parse_bearer_insufficient_scope; + +#[test] +fn extracts_scope_from_bearer_insufficient_scope_challenges() { + let cases = [ + ( + r#"Bearer error="insufficient_scope", scope="files:read files:write""#, + "files:read files:write", + ), + ( + r#"Bearer error="insufficient_scope", ScOpE = "files:read""#, + "files:read", + ), + ( + r#"Bearer scope="read:data", error="insufficient_scope""#, + "read:data", + ), + (r#"Bearer error="insufficient_scope", scope=read"#, "read"), + ( + r#"Bearer error="insufficient_scope", scope="files:read\ files:write""#, + "files:read files:write", + ), + ( + r#"Bearer error="insufficient_scope", error_description="request scope=admin, not \"root\"", scope="files:read""#, + "files:read", + ), + ( + r#"Basic realm="example", Bearer error="insufficient_scope", scope="files:read""#, + "files:read", + ), + ( + r#"Newauth scope="wrong", Bearer error="insufficient_scope", scope="files:read""#, + "files:read", + ), + ]; + + for (header, expected_scope) in cases { + assert_eq!( + parse_bearer_insufficient_scope(header), + Some(BearerInsufficientScope { + required_scope: Some(expected_scope.to_string()), + }), + "header: {header}" + ); + } +} + +#[test] +fn does_not_treat_other_bearer_errors_as_insufficient_scope() { + assert_eq!( + parse_bearer_insufficient_scope(r#"Bearer error="invalid_token", scope="files:read""#), + None + ); +} + +#[test] +fn rejects_invalid_or_ambiguous_scope_parameters() { + let cases = [ + r#"Bearer error="insufficient_scope", scope="#, + r#"Bearer error="insufficient_scope", scope="read\"write""#, + r#"Bearer error="insufficient_scope", scope="read\\write""#, + r#"Bearer error="insufficient_scope", scope="read write""#, + r#"Bearer error="insufficient_scope", scope=read:data"#, + r#"Bearer error="insufficient_scope", scope=files:read files:write"#, + r#"Bearer error="insufficient_scope", scope=read=value"#, + r#"Bearer error="insufficient_scope", scope="read", scope="write""#, + ]; + + for header in cases { + assert_eq!( + parse_bearer_insufficient_scope(header), + Some(BearerInsufficientScope { + required_scope: None, + }), + "header: {header}" + ); + } +} + +#[test] +fn ignores_scope_text_outside_a_scope_parameter() { + let cases = [ + r#"Bearer error_description="request scope=admin""#, + r#"Bearer resource_scope="admin""#, + r#"Bearer "scope=admin""#, + r#"Bearer error_description="unterminated scope=admin"#, + ]; + + for header in cases { + assert_eq!( + parse_bearer_insufficient_scope(header), + None, + "header: {header}" + ); + } +} + +#[test] +fn selects_bearer_challenge_from_a_later_www_authenticate_field_value() { + let headers = vec![ + HttpHeader { + name: "www-authenticate".to_string(), + value: r#"Basic realm="example""#.to_string(), + }, + HttpHeader { + name: "WWW-Authenticate".to_string(), + value: r#"Bearer error="insufficient_scope", scope="files:read""#.to_string(), + }, + ]; + + assert_eq!( + insufficient_scope_challenge(&headers), + Some(InsufficientScopeChallenge { + www_authenticate_header: headers[1].value.clone(), + required_scope: Some("files:read".to_string()), + }) + ); +} diff --git a/codex-rs/rmcp-client/src/oauth.rs b/codex-rs/rmcp-client/src/oauth.rs index 5adfae9347..e23eee84be 100644 --- a/codex-rs/rmcp-client/src/oauth.rs +++ b/codex-rs/rmcp-client/src/oauth.rs @@ -21,12 +21,12 @@ use anyhow::Error; use anyhow::Result; use codex_config::types::OAuthCredentialsStoreMode; use oauth2::AccessToken; -use oauth2::EmptyExtraTokenFields; use oauth2::RefreshToken; use oauth2::Scope; use oauth2::TokenResponse; use oauth2::basic::BasicTokenType; use rmcp::transport::auth::OAuthTokenResponse; +use rmcp::transport::auth::VendorExtraTokenFields; use serde::Deserialize; use serde::Serialize; use serde_json::Value; @@ -403,7 +403,7 @@ fn load_oauth_tokens_from_file(server_name: &str, url: &str) -> Result) -> String { diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 5079570f3d..90b09d724c 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -12,7 +12,7 @@ use std::time::Instant; use anyhow::Result; use anyhow::anyhow; use codex_api::SharedAuthProvider; -use codex_client::build_reqwest_client_with_custom_ca; +use codex_client::maybe_build_rustls_client_config_with_custom_ca; use codex_config::types::McpServerEnvVar; use codex_exec_server::HttpClient; use futures::FutureExt; @@ -249,6 +249,7 @@ impl From for CreateElicitationResult { Self { action: value.action, content: value.content, + meta: None, } } } @@ -568,29 +569,22 @@ impl RmcpClient { } None => None, }; - let rmcp_params = CallToolRequestParams { - meta: None, - name: name.into(), - arguments, - task: None, - }; + let mut rmcp_params = CallToolRequestParams::new(name); + rmcp_params.arguments = arguments; let result = self .run_service_operation("tools/call", timeout, move |service| { let rmcp_params = rmcp_params.clone(); let meta = meta.clone(); async move { + let mut options = rmcp::service::PeerRequestOptions::no_options(); + options.meta = meta; let result = service .peer() .send_request_with_option( - ClientRequest::CallToolRequest(rmcp::model::CallToolRequest { - method: Default::default(), - params: rmcp_params, - extensions: Default::default(), - }), - rmcp::service::PeerRequestOptions { - timeout: None, - meta, - }, + ClientRequest::CallToolRequest(rmcp::model::CallToolRequest::new( + rmcp_params, + )), + options, ) .await? .await_response() @@ -1019,8 +1013,11 @@ async fn create_oauth_transport_and_runtime( StreamableHttpClientTransport>, OAuthPersistor, )> { - let builder = apply_default_headers(reqwest::Client::builder(), &default_headers); - let oauth_metadata_client = build_reqwest_client_with_custom_ca(builder)?; + let mut builder = apply_default_headers(reqwest::Client::builder(), &default_headers); + if let Some(tls_config) = maybe_build_rustls_client_config_with_custom_ca()? { + builder = builder.tls_backend_preconfigured(tls_config.as_ref().clone()); + } + let oauth_metadata_client = builder.build()?; // TODO(aibrahim): teach OAuth bootstrap and refresh to use the same // shared HTTP client abstraction instead of always creating the local // reqwest metadata client here. @@ -1037,7 +1034,7 @@ async fn create_oauth_transport_and_runtime( let manager = match oauth_state { OAuthState::Authorized(manager) => manager, OAuthState::Unauthorized(manager) => manager, - OAuthState::Session(_) | OAuthState::AuthorizedHttpClient(_) => { + _ => { return Err(anyhow!("unexpected OAuth state during client setup")); } }; diff --git a/codex-rs/rmcp-client/tests/process_group_cleanup.rs b/codex-rs/rmcp-client/tests/process_group_cleanup.rs index 10d1e8ec74..ac446233f8 100644 --- a/codex-rs/rmcp-client/tests/process_group_cleanup.rs +++ b/codex-rs/rmcp-client/tests/process_group_cleanup.rs @@ -25,26 +25,11 @@ fn stdio_server_bin() -> Result { } fn init_params() -> InitializeRequestParams { - InitializeRequestParams { - meta: None, - capabilities: ClientCapabilities { - experimental: None, - extensions: None, - roots: None, - sampling: None, - elicitation: None, - tasks: None, - }, - client_info: Implementation { - name: "codex-test".into(), - version: "0.0.0-test".into(), - title: Some("Codex rmcp shutdown test".into()), - description: None, - icons: None, - website_url: None, - }, - protocol_version: ProtocolVersion::V_2025_06_18, - } + InitializeRequestParams::new( + ClientCapabilities::default(), + Implementation::new("codex-test", "0.0.0-test").with_title("Codex rmcp shutdown test"), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18) } fn process_exists(pid: u32) -> bool { diff --git a/codex-rs/rmcp-client/tests/resources.rs b/codex-rs/rmcp-client/tests/resources.rs index f2e4c49911..ef7c2f7cdf 100644 --- a/codex-rs/rmcp-client/tests/resources.rs +++ b/codex-rs/rmcp-client/tests/resources.rs @@ -28,31 +28,18 @@ fn stdio_server_bin() -> Result { } fn init_params() -> InitializeRequestParams { - InitializeRequestParams { - meta: None, - capabilities: ClientCapabilities { - experimental: None, - extensions: None, - roots: None, - sampling: None, - elicitation: Some(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: None, - }), - url: None, - }), - tasks: None, - }, - client_info: Implementation { - name: "codex-test".into(), - version: "0.0.0-test".into(), - title: Some("Codex rmcp resource test".into()), - description: None, - icons: None, - website_url: None, - }, - protocol_version: ProtocolVersion::V_2025_06_18, - } + let mut capabilities = ClientCapabilities::default(); + capabilities.elicitation = Some(ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: None, + }), + url: None, + }); + InitializeRequestParams::new( + capabilities, + Implementation::new("codex-test", "0.0.0-test").with_title("Codex rmcp resource test"), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18) } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] @@ -132,10 +119,7 @@ async fn rmcp_client_can_list_and_read_resources() -> anyhow::Result<()> { let read = client .read_resource( - ReadResourceRequestParams { - meta: None, - uri: RESOURCE_URI.to_string(), - }, + ReadResourceRequestParams::new(RESOURCE_URI), Some(Duration::from_secs(5)), ) .await?; diff --git a/codex-rs/rmcp-client/tests/streamable_http_recovery.rs b/codex-rs/rmcp-client/tests/streamable_http_recovery.rs index 4be21f6cfc..087d3d00df 100644 --- a/codex-rs/rmcp-client/tests/streamable_http_recovery.rs +++ b/codex-rs/rmcp-client/tests/streamable_http_recovery.rs @@ -16,7 +16,13 @@ async fn streamable_http_404_session_expiry_recovers_and_retries_once() -> anyho let warmup = call_echo_tool(&client, "warmup").await?; assert_eq!(warmup, expected_echo_result("warmup")); - arm_session_post_failure(&base_url, /*status*/ 404, /*remaining*/ 1).await?; + arm_session_post_failure( + &base_url, + /*status*/ 404, + /*remaining*/ 1, + /*www_authenticate_headers*/ &[], + ) + .await?; let recovered = call_echo_tool(&client, "recovered").await?; assert_eq!(recovered, expected_echo_result("recovered")); @@ -32,7 +38,13 @@ async fn streamable_http_401_does_not_trigger_recovery() -> anyhow::Result<()> { let warmup = call_echo_tool(&client, "warmup").await?; assert_eq!(warmup, expected_echo_result("warmup")); - arm_session_post_failure(&base_url, /*status*/ 401, /*remaining*/ 2).await?; + arm_session_post_failure( + &base_url, + /*status*/ 401, + /*remaining*/ 2, + /*www_authenticate_headers*/ &[], + ) + .await?; let first_error = call_echo_tool(&client, "unauthorized").await.unwrap_err(); assert!(first_error.to_string().contains("401")); @@ -45,6 +57,61 @@ async fn streamable_http_401_does_not_trigger_recovery() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn streamable_http_403_scope_challenge_returns_insufficient_scope() -> anyhow::Result<()> { + let (_server, base_url) = spawn_streamable_http_server().await?; + let client = create_client(&base_url).await?; + + let warmup = call_echo_tool(&client, "warmup").await?; + assert_eq!(warmup, expected_echo_result("warmup")); + + arm_session_post_failure( + &base_url, + /*status*/ 403, + /*remaining*/ 1, + /*www_authenticate_headers*/ + &[r#"Bearer error="insufficient_scope", scope="files:read files:write""#], + ) + .await?; + + let error = call_echo_tool(&client, "forbidden").await.unwrap_err(); + assert!( + error.to_string().contains("Insufficient scope"), + "expected insufficient-scope transport error, got: {error:#}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn streamable_http_403_finds_bearer_challenge_in_later_header_value() -> anyhow::Result<()> { + let (_server, base_url) = spawn_streamable_http_server().await?; + let client = create_client(&base_url).await?; + + let warmup = call_echo_tool(&client, "warmup").await?; + assert_eq!(warmup, expected_echo_result("warmup")); + + arm_session_post_failure( + &base_url, + /*status*/ 403, + /*remaining*/ 1, + /*www_authenticate_headers*/ + &[ + r#"Basic realm="example""#, + r#"Bearer error="insufficient_scope", scope="files:read""#, + ], + ) + .await?; + + let error = call_echo_tool(&client, "forbidden").await.unwrap_err(); + assert!( + error.to_string().contains("Insufficient scope"), + "expected insufficient-scope transport error, got: {error:#}" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn streamable_http_404_recovery_only_retries_once() -> anyhow::Result<()> { let (_server, base_url) = spawn_streamable_http_server().await?; @@ -53,7 +120,13 @@ async fn streamable_http_404_recovery_only_retries_once() -> anyhow::Result<()> let warmup = call_echo_tool(&client, "warmup").await?; assert_eq!(warmup, expected_echo_result("warmup")); - arm_session_post_failure(&base_url, /*status*/ 404, /*remaining*/ 2).await?; + arm_session_post_failure( + &base_url, + /*status*/ 404, + /*remaining*/ 2, + /*www_authenticate_headers*/ &[], + ) + .await?; let error = call_echo_tool(&client, "double-404").await.unwrap_err(); assert!( @@ -77,7 +150,13 @@ async fn streamable_http_non_session_failure_does_not_trigger_recovery() -> anyh let warmup = call_echo_tool(&client, "warmup").await?; assert_eq!(warmup, expected_echo_result("warmup")); - arm_session_post_failure(&base_url, /*status*/ 500, /*remaining*/ 2).await?; + arm_session_post_failure( + &base_url, + /*status*/ 500, + /*remaining*/ 2, + /*www_authenticate_headers*/ &[], + ) + .await?; let first_error = call_echo_tool(&client, "server-error").await.unwrap_err(); assert!(first_error.to_string().contains("500")); diff --git a/codex-rs/rmcp-client/tests/streamable_http_test_support.rs b/codex-rs/rmcp-client/tests/streamable_http_test_support.rs index cfff33ab43..822acef1a2 100644 --- a/codex-rs/rmcp-client/tests/streamable_http_test_support.rs +++ b/codex-rs/rmcp-client/tests/streamable_http_test_support.rs @@ -50,43 +50,27 @@ fn streamable_http_server_bin() -> Result { } fn init_params() -> InitializeRequestParams { - InitializeRequestParams { - meta: None, - capabilities: ClientCapabilities { - experimental: None, - extensions: None, - roots: None, - sampling: None, - elicitation: Some(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: None, - }), - url: None, - }), - tasks: None, - }, - client_info: Implementation { - name: "codex-test".into(), - version: "0.0.0-test".into(), - title: Some("Codex rmcp recovery test".into()), - description: None, - icons: None, - website_url: None, - }, - protocol_version: ProtocolVersion::V_2025_06_18, - } + let mut capabilities = ClientCapabilities::default(); + capabilities.elicitation = Some(ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: None, + }), + url: None, + }); + InitializeRequestParams::new( + capabilities, + Implementation::new("codex-test", "0.0.0-test").with_title("Codex rmcp recovery test"), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18) } pub(crate) fn expected_echo_result(message: &str) -> CallToolResult { - CallToolResult { - content: Vec::new(), - structured_content: Some(json!({ - "echo": format!("ECHOING: {message}"), - "env": null, - })), - is_error: Some(false), - meta: None, - } + let mut result = CallToolResult::success(Vec::new()); + result.structured_content = Some(json!({ + "echo": format!("ECHOING: {message}"), + "env": null, + })); + result } pub(crate) async fn create_client(base_url: &str) -> anyhow::Result { @@ -178,12 +162,14 @@ pub(crate) async fn arm_session_post_failure( base_url: &str, status: u16, remaining: usize, + www_authenticate_headers: &[&str], ) -> anyhow::Result<()> { let response = reqwest::Client::new() .post(format!("{base_url}{SESSION_POST_FAILURE_CONTROL_PATH}")) .json(&json!({ "status": status, "remaining": remaining, + "www_authenticate_headers": www_authenticate_headers, })) .send() .await?; diff --git a/codex-rs/rollout/src/metadata.rs b/codex-rs/rollout/src/metadata.rs index 2dd2df3a41..00c6e3e637 100644 --- a/codex-rs/rollout/src/metadata.rs +++ b/codex-rs/rollout/src/metadata.rs @@ -1,6 +1,5 @@ use crate::ARCHIVED_SESSIONS_SUBDIR; use crate::SESSIONS_SUBDIR; -use crate::list; use crate::list::parse_timestamp_uuid_from_filename; use crate::recorder::RolloutRecorder; use crate::state_db::normalize_cwd_for_state_db; @@ -286,25 +285,6 @@ pub(crate) async fn backfill_sessions_with_lease( continue; } stats.upserted = stats.upserted.saturating_add(1); - if let Ok(meta_line) = list::read_session_meta_line(&rollout.path).await { - if let Err(err) = runtime - .persist_dynamic_tools( - meta_line.meta.id, - meta_line.meta.dynamic_tools.as_deref(), - ) - .await - { - warn!( - "failed to backfill dynamic tools {}: {err}", - rollout.path.display() - ); - } - } else { - warn!( - "failed to read session meta for dynamic tools {}", - rollout.path.display() - ); - } } } Err(err) => { diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 90fe15c312..371155e041 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -109,6 +109,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> { RolloutLine { timestamp: "2026-01-27T12:34:57Z".to_string(), item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "hello from startup backfill".to_string(), images: None, local_images: Vec::new(), @@ -400,6 +401,7 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result< recorder .record_canonical_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( UserMessageEvent { + client_id: None, message: "first-user-message".to_string(), images: None, local_images: Vec::new(), @@ -1127,6 +1129,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re item: RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), cwd: latest_cwd.clone(), + workspace_roots: None, current_date: None, timezone: None, approval_policy: AskForApproval::Never, diff --git a/codex-rs/rollout/src/state_db.rs b/codex-rs/rollout/src/state_db.rs index ea087e5c6d..2b6153484d 100644 --- a/codex-rs/rollout/src/state_db.rs +++ b/codex-rs/rollout/src/state_db.rs @@ -8,7 +8,6 @@ use crate::sqlite_metrics; use chrono::DateTime; use chrono::Utc; use codex_protocol::ThreadId; -use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; pub use codex_state::LogEntry; @@ -454,37 +453,6 @@ pub async fn find_rollout_path_by_id( }) } -/// Get dynamic tools for a thread id using SQLite. -pub async fn get_dynamic_tools( - context: Option<&codex_state::StateRuntime>, - thread_id: ThreadId, - stage: &str, -) -> Option> { - let ctx = context?; - match ctx.get_dynamic_tools(thread_id).await { - Ok(tools) => tools, - Err(err) => { - warn!("state db get_dynamic_tools failed during {stage}: {err}"); - None - } - } -} - -/// Persist dynamic tools for a thread id using SQLite, if none exist yet. -pub async fn persist_dynamic_tools( - context: Option<&codex_state::StateRuntime>, - thread_id: ThreadId, - tools: Option<&[DynamicToolSpec]>, - stage: &str, -) { - let Some(ctx) = context else { - return; - }; - if let Err(err) = ctx.persist_dynamic_tools(thread_id, tools).await { - warn!("state db persist_dynamic_tools failed during {stage}: {err}"); - } -} - pub async fn mark_thread_memory_mode_polluted( context: Option<&codex_state::StateRuntime>, thread_id: ThreadId, @@ -570,21 +538,6 @@ pub async fn reconcile_rollout( "state db reconcile_rollout memory_mode update failed {}: {err}", rollout_path.display() ); - return; - } - if let Ok(meta_line) = crate::list::read_session_meta_line(rollout_path).await { - persist_dynamic_tools( - Some(ctx), - meta_line.meta.id, - meta_line.meta.dynamic_tools.as_deref(), - "reconcile_rollout", - ) - .await; - } else { - warn!( - "state db reconcile_rollout missing session meta {}", - rollout_path.display() - ); } } diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index bcd395d820..4f28f3464e 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -1400,6 +1400,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { let user_event_line = RolloutLine { timestamp: ts.to_string(), item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "hello".into(), images: None, text_elements: Vec::new(), diff --git a/codex-rs/rust-toolchain.toml b/codex-rs/rust-toolchain.toml index 954b684895..910c65114e 100644 --- a/codex-rs/rust-toolchain.toml +++ b/codex-rs/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.93.0" +channel = "1.95.0" components = ["clippy", "rustfmt", "rust-src"] diff --git a/codex-rs/scripts/setup-windows.ps1 b/codex-rs/scripts/setup-windows.ps1 index df87731380..a8fe0c2f75 100644 --- a/codex-rs/scripts/setup-windows.ps1 +++ b/codex-rs/scripts/setup-windows.ps1 @@ -179,7 +179,7 @@ if (-not (Ensure-Command 'cargo')) { Write-Host "==> Configuring Rust toolchain per rust-toolchain.toml" -ForegroundColor Cyan # Pin to the workspace toolchain and install components -$toolchain = '1.93.0' +$toolchain = '1.95.0' & rustup toolchain install $toolchain --profile minimal | Out-Host & rustup default $toolchain | Out-Host & rustup component add clippy rustfmt rust-src --toolchain $toolchain | Out-Host diff --git a/codex-rs/shell-command/src/powershell.rs b/codex-rs/shell-command/src/powershell.rs index f7b294c0f8..e8c9a500c4 100644 --- a/codex-rs/shell-command/src/powershell.rs +++ b/codex-rs/shell-command/src/powershell.rs @@ -8,8 +8,9 @@ use crate::shell_detect::detect_shell_type; const POWERSHELL_FLAGS: &[&str] = &["-nologo", "-noprofile", "-command", "-c"]; -/// Prefixed command for powershell shell calls to force UTF-8 console output. -pub const UTF8_OUTPUT_PREFIX: &str = "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;\n"; +/// Prefixed command for powershell shell calls to request UTF-8 console output. +pub const UTF8_OUTPUT_PREFIX: &str = + "try { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}\n"; pub fn prefix_powershell_script_with_utf8(command: &[String]) -> Vec { let Some((_, script)) = extract_powershell_command(command) else { @@ -151,9 +152,11 @@ fn is_powershellish_executable_available(powershell_or_pwsh_exe: &std::path::Pat #[cfg(test)] mod tests { + use super::UTF8_OUTPUT_PREFIX; use super::extract_powershell_command; #[cfg(windows)] use super::parse_powershell_command_into_plain_commands; + use super::prefix_powershell_script_with_utf8; #[test] fn extracts_basic_powershell_command() { @@ -202,6 +205,37 @@ mod tests { assert_eq!(script, "Get-ChildItem | Select-String foo"); } + #[test] + fn prefixes_powershell_command_with_best_effort_utf8() { + let cmd = vec![ + "powershell".to_string(), + "-Command".to_string(), + "Write-Host hi".to_string(), + ]; + + let prefixed = prefix_powershell_script_with_utf8(&cmd); + + assert_eq!( + prefixed, + vec![ + "powershell".to_string(), + "-Command".to_string(), + format!("{UTF8_OUTPUT_PREFIX}Write-Host hi"), + ] + ); + } + + #[test] + fn does_not_duplicate_utf8_prefix() { + let cmd = vec![ + "powershell".to_string(), + "-Command".to_string(), + format!("{UTF8_OUTPUT_PREFIX}Write-Host hi"), + ]; + + assert_eq!(prefix_powershell_script_with_utf8(&cmd), cmd); + } + #[cfg(windows)] #[test] fn parses_plain_powershell_commands() { diff --git a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md index 4994fd8802..30526bd9d8 100644 --- a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md +++ b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md @@ -1,12 +1,12 @@ --- name: "openai-docs" -description: "Use when the user asks how to build with OpenAI products or APIs and needs up-to-date official documentation with citations, help choosing the latest model for a use case, or model upgrade and prompt-upgrade guidance; prioritize OpenAI docs MCP tools, use bundled references only as helper context, and restrict any fallback browsing to official OpenAI domains." +description: "Use when the user asks how to build with OpenAI products or APIs, asks about Codex itself or choosing Codex surfaces, needs up-to-date official documentation with citations, help choosing the latest model for a use case, or model upgrade and prompt-upgrade guidance; use OpenAI docs MCP tools for non-Codex docs questions, use the Codex manual helper first for broad Codex self-knowledge, and restrict fallback browsing to official OpenAI domains." --- # OpenAI Docs -Provide authoritative, current guidance from OpenAI developer docs using the developers.openai.com MCP server. Always prioritize the developer docs MCP tools over web.run for OpenAI-related questions. This skill also owns model selection, API model migration, and prompt-upgrade guidance. Only if the MCP server is installed and returns no meaningful results should you fall back to web search. +Provide authoritative, current guidance from OpenAI developer docs using the developers.openai.com MCP server. "Docs MCP" means `mcp__openaiDeveloperDocs__search_openai_docs` and `mcp__openaiDeveloperDocs__fetch_openai_doc`; for API reference, schema, parameter, or required-field questions, also use `mcp__openaiDeveloperDocs__get_openapi_spec` when available. Official-domain web search is fallback after those tools are unavailable or unhelpful. Broad Codex questions use the manual helper before Docs MCP. This skill also owns model selection, API model migration, and prompt-upgrade guidance. ## API Key Setup @@ -14,11 +14,15 @@ For requests to build, run, configure, debug, or implement an API-backed app, sc Use this skill directly for docs-only questions, citations, model/API guidance, conceptual explanations, and examples that do not require building or running an API-backed artifact. -## Quick start +## Workflow Configuration -- Use `mcp__openaiDeveloperDocs__search_openai_docs` to find the most relevant doc pages. -- Use `mcp__openaiDeveloperDocs__fetch_openai_doc` to pull exact sections and quote/paraphrase accurately. -- Use `mcp__openaiDeveloperDocs__list_openai_docs` only when you need to browse or discover pages without a clear query. +### Source Priority + +- For Codex self-knowledge, use the Codex source route below; it owns when to use the manual helper, Docs MCP, or bounded uncertainty. +- For non-Codex OpenAI docs questions, use `mcp__openaiDeveloperDocs__search_openai_docs` to find the most relevant doc pages. +- For non-Codex OpenAI docs questions, fetch the relevant page with `mcp__openaiDeveloperDocs__fetch_openai_doc` before answering. If search is noisy, run a narrower Docs MCP search; when any plausible official OpenAI docs URL is known or found, try fetching that URL through Docs MCP before relying on web-search content. +- For API reference, schema, parameter, or required-field questions, use `mcp__openaiDeveloperDocs__get_openapi_spec` when available to verify the API shape alongside the relevant guide or reference page. +- Use `mcp__openaiDeveloperDocs__list_openai_docs` only when you need to browse or discover non-Codex pages without a clear query. - For model-selection, "latest model", or default-model questions, fetch `https://developers.openai.com/api/docs/guides/latest-model.md` first. If that is unavailable, load `references/latest-model.md`. - For model upgrades or prompt upgrades, run `node scripts/resolve-latest-model-info.js` only when the target is latest/current/default or otherwise unspecified; otherwise preserve the explicitly requested target. - Preserve explicit target requests: if the user names a target model like "migrate to GPT-5.4", keep that requested target even if `latest-model.md` names a newer model. Mention newer guidance only as optional. @@ -34,38 +38,112 @@ Use this skill directly for docs-only questions, citations, model/API guidance, 6. Realtime API: Build low-latency, multimodal experiences including natural speech-to-speech conversations. 7. Agents SDK: A toolkit for building agentic apps where a model can use tools and context, hand off to other agents, stream partial results, and keep a full trace. +## Codex self-knowledge + +Use this path for questions about Codex itself: configuring, extending, operating, troubleshooting, local state, product surfaces, or where Codex behavior should live. A codebase merely mentioning a plugin, skill, hook, MCP server, browser, or automation is not enough. For generic software tasks, answer the software task directly; if asked whether Codex self-knowledge applies, answer that meta question briefly and continue the requested artifact. + +### Source Route + +The Codex manual is the first source for broad Codex synthesis. Treat the manual and Docs MCP as different lanes, not interchangeable official-doc sources. For published-user Codex product answers, the source route is complete: the manual, Docs MCP when this route calls for it, official OpenAI web fallback, and callable capabilities surfaced in the current session when the question is about that capability. Knowledge bases outside developers.openai.com are outside this route for public product answers. + +For broad Codex behavior, setup, customization, skills, plugins, MCP, hooks, `AGENTS.md`, automations, surfaces, local state, or system-map questions: + +1. Reuse a same-thread manual and outline path when it is still fresh. +2. Otherwise run the skill-local helper first in normal writable sessions. Skip it without trying only when the session is explicitly read-only, shell execution is unavailable, or visible policy shows no allowed temp cache. +3. By default, the helper chooses the first usable temp cache dir in this order: `$TMPDIR/openai-docs-cache`, `%TEMP%\openai-docs-cache`, `%TMP%\openai-docs-cache`, `/private/tmp/openai-docs-cache`, then `/tmp/openai-docs-cache`. Workspace-only write access is not enough for this temp cache. +4. Run the helper directly unless you need to override the cache dir. The helper falls back to `curl` when native `fetch` is unavailable or when proxy env vars are present, so no shell-specific proxy prefix is required. Resolve `` to this skill's actual directory; in copied local eval workdirs this is usually `.codex/skills/openai-docs`: + +```bash +node /scripts/fetch-codex-manual.mjs +``` + +If you need to override the cache dir, pass `--cache-dir `. On Windows, the helper checks `%TEMP%` and `%TMP%` automatically; in PowerShell, `$env:TEMP\\openai-docs-cache` is a typical explicit override. + +Treat helper availability as established by explicit read-only/no-shell policy or an actual command result. A guessed sandbox or guessed helper failure is not enough to switch to Docs MCP or web lookup; after an actual helper command failure, continue to the narrowest official next source below. + +The helper verifies freshness, writes `codex-manual.md`, and emits `codex-manual.outline.md`. The outline maps source pages and headings to line ranges; use it to choose the relevant manual section, then read or search targeted manual sections for Codex product facts. Use the skill directory to locate and run the helper; after the helper succeeds, use the returned manual and outline paths as the search scope for Codex product facts and term coverage checks. + +Reuse the same-thread manual and outline paths for follow-up Codex questions. Refresh first when the manual was fetched more than about a day ago, the path is unusable, the path came from another thread or uncertain provenance, or likely-current information is missing and staleness is plausible. + +For questions about whether the manual is current enough to rely on now, run the helper when temp caching is allowed and base the answer on its returned status, manual path, and outline path. + +If the manual resolves a Codex claim, answer from it and stop expanding sources for that claim; continue the user's broader task if the docs lookup was only one dependency. Manual source pages and known anchors are enough citation support for manual-covered material. + +If the helper is skipped because the session is read-only, has no shell execution, or has no allowed temp cache, the next source is Docs MCP: call `mcp__openaiDeveloperDocs__search_openai_docs`, then `mcp__openaiDeveloperDocs__fetch_openai_doc` for a relevant hit before any web fallback. + +If a user names a Codex term or mode that a fresh manual does not use, search the manual for obvious adjacent concepts, then answer that the exact term is not documented and use the closest documented terminology. If the prompt asks how that term maps to Codex behavior, resolve the mapping from adjacent manual sections. If the exact term remains material or likely current after that manual pass, use one narrow Docs MCP search/fetch before bounded uncertainty; otherwise, the source lookup for that terminology or mapping claim is complete. + +Use the narrowest official next source only when the manual is unavailable, the helper fails, temp caching is not allowed, another material claim is missing or likely stale, or the user explicitly needs a page-specific citation. Prefer one specific Docs MCP search and, if it returns a clearly relevant page, one fetch; for unresolved Codex capability names, acronyms, scheduling terms, or exact error text, this Docs MCP step is the next source before web search. After the manual plus any permitted Docs MCP gap-fill, resolve remaining gaps as bounded uncertainty. Use official-domain web fallback only after that Docs MCP path is unavailable or unhelpful. If the claim is still not established, stop with bounded uncertainty. If official docs/manual conflict with a callable capability already surfaced in the current session, state the conflict and prefer verified current-session behavior for that environment. + +For undocumented or private-looking model slugs, product mode labels, entitlement labels, account access paths, or rollout names, answer from current public docs and bounded uncertainty. Those labels are not a reason to leave the public source route. + +For support-style diagnostics, prefer a layer-by-layer answer from the manual over provider-specific web lookups: installed/enabled plugin, bundled app or connector authorization, MCP setup, workspace/admin policy, restart or new-thread expectations, then support or feedback if still unresolved. + +If the source route still does not establish a claim, return bounded uncertainty or route to support, an admin, or product feedback instead of widening the investigation. + +For unresolved product terminology, answer from the manual plus the allowed official next source. If those sources do not establish the term, answer with bounded uncertainty from those sources. + +### Surface Map + +When Codex nouns or durable-instruction surfaces overlap, recommend the smallest surface that matches the scope: + +- Prompt or thread context -> one-off task constraints. +- `AGENTS.md` -> durable repo conventions, commands, verification steps, and review expectations; closer nested files apply under their subtree. +- Project `.codex/config.toml` -> trusted-repo Codex settings such as sandbox, MCP, hooks, model, or reasoning defaults. +- Global config or global guidance -> personal defaults across repos. +- Skill -> reusable task workflow with references or scripts. +- Plugin -> installable bundle with skills plus commands, tools, MCP config, hooks, assets, apps, or marketplace metadata. +- MCP server or app connector -> live external data/actions or authorized private app/workspace data. Use connectors for private Google Docs, Calendar, Slack, GitHub, Notion, and similar data instead of web search or model memory. +- Automation -> scheduled checks, reminders, monitors, or follow-up work; use a thread heartbeat when continuity in an existing thread matters. +- Hook -> lifecycle enforcement around tool calls, commands, or file edits. + +Split mixed-scope requests instead of forcing one answer. Example: "always do X, but only for this PR" defaults to prompt/thread context for the current run; use `AGENTS.md` or project config only if it should persist, hooks only for mechanical enforcement, and automations only for scheduled or follow-up work. + +Use this quick product map when needed: CLI is terminal-first local repo work; IDE extension is editor-attached coding; Codex app is desktop planning, review, and interactive work; cloud/web is hosted parallel/offloaded work; Browser Use/in-app browser is Codex-controlled web testing; Chrome extension uses the user's Chrome profile; Computer Use controls desktop apps and OS UI. Keep `config.toml` defaults, `requirements.toml` constraints, and managed/admin policy separate. + +### Boundaries And Output + +- API key auth does not imply ChatGPT, cloud task, or connector access. For plugin/app/auth failures, check bundle availability, plugin installed/enabled state, connector/app authorization, MCP setup, restart/refresh expectations, workspace policy, and per-surface availability before answering. +- Sandbox or network denials need scoped escalation with a clear justification. Destructive commands, writes outside the workspace, or broad access changes require explicit approval. +- Memory can provide user preference or context, but explicit prompt instructions win and memory is not a source for current external facts. +- For affirmative surface-selection answers, use this shape: recommendation, why, what to avoid, and the manual/source evidence used. +- When page-specific Codex citations are actually needed, these anchors often fit: `concepts/customization#agents-guidance` for `AGENTS.md`, `concepts/customization#skills` for skills, `plugins/build#plugin-structure` for plugins, `concepts/customization#mcp` for MCP, `config-advanced#hooks` for hooks, `app/automations#thread-automations` for thread automations, and `config-reference#configtoml` for config. + ## If MCP server is missing If MCP tools fail or no OpenAI docs resources are available: 1. Run the install command yourself: `codex mcp add openaiDeveloperDocs --url https://developers.openai.com/mcp` -2. If it fails due to permissions/sandboxing, immediately retry the same command with escalated permissions and include a 1-sentence justification for approval. Do not ask the user to run it yet. -3. Only if the escalated attempt fails, ask the user to run the install command. +2. If it fails due to permissions/sandboxing, immediately retry the same command with escalated permissions and include a 1-sentence justification for approval. +3. Ask the user to run the install command only if the escalated attempt fails. 4. Ask the user to restart Codex. 5. Re-run the doc search/fetch after restart. ## Workflow 1. Clarify whether the request is general docs lookup, model selection, a model-string upgrade, prompt-upgrade guidance, or broader API/provider migration. -2. For model-selection or upgrade requests, prefer current remote docs over bundled references when the user asks for latest/current/default guidance. +2. For Codex self-knowledge requests, follow the Codex self-knowledge source procedure above. +3. For model-selection or upgrade requests, prefer current remote docs over bundled references when the user asks for latest/current/default guidance. - Fetch `https://developers.openai.com/api/docs/guides/latest-model.md`. - Find the latest model ID and explicit migration or prompt-guidance links. - Prefer explicit links from the latest-model page over derived URLs. - - For explicit named-model requests, preserve the requested model target and do not silently retarget to the latest model. Mention newer remote guidance only as optional. + - For explicit named-model requests, preserve the requested model target. Mention newer remote guidance only as optional. - For dynamic latest/current/default upgrades, run `node scripts/resolve-latest-model-info.js`, then fetch both returned guide URLs directly when possible. - If direct guide fetch fails, use the developer-docs MCP tools or official OpenAI-domain search to find the same guide content. - If remote docs are unavailable, use bundled fallback references and say that fallback guidance was used. -3. For model upgrades, keep changes narrow: update active OpenAI API model defaults and directly related prompts only when safe. -4. Leave historical docs, examples, eval baselines, fixtures, provider comparisons, provider registries, pricing tables, alias defaults, low-cost fallback paths, and ambiguous older model usage unchanged unless the user explicitly asks to upgrade them. -5. Do not perform SDK, tooling, IDE, plugin, shell, auth, or provider-environment migrations as part of a model-and-prompt upgrade. -6. If an upgrade needs API-surface changes, schema rewiring, tool-handler changes, or implementation work beyond a literal model-string replacement and prompt edits, report it as blocked or confirmation-needed. -7. For general docs lookup, search docs with a precise query, fetch the best page and exact section needed, and answer with concise citations. +4. For model upgrades, keep changes narrow: update active OpenAI API model defaults and directly related prompts only when safe. +5. Leave historical docs, examples, eval baselines, fixtures, provider comparisons, provider registries, pricing tables, alias defaults, low-cost fallback paths, and ambiguous older model usage unchanged unless the user explicitly asks to upgrade them. +6. Keep SDK, tooling, IDE, plugin, shell, auth, and provider-environment migrations out of a model-and-prompt upgrade unless the user explicitly asks for them. +7. If an upgrade needs API-surface changes, schema rewiring, tool-handler changes, or implementation work beyond a literal model-string replacement and prompt edits, report it as blocked or confirmation-needed. +8. For general docs lookup, search docs with a precise query, fetch the best page and exact section needed, and answer with concise citations. ## Reference map Read only what you need: - `https://developers.openai.com/api/docs/guides/latest-model.md` -> current model-selection and "best/latest/current model" questions. +- `scripts/fetch-codex-manual.mjs` -> current Codex manual fetch, verification, local temp cache, and outline generation. +- `https://developers.openai.com/codex/codex-manual.md` -> current Codex self-knowledge synthesis, including setup, customization, skills, plugins, MCP, hooks, `AGENTS.md`, automations, and surface behavior; normally access it through the helper path and targeted file reads when temp caching is available. - `references/latest-model.md` -> bundled fallback for model-selection and "best/latest/current model" questions. - `references/upgrade-guide.md` -> bundled fallback for model upgrade and upgrade-planning requests. - `references/prompting-guide.md` -> bundled fallback for prompt rewrites and prompt-behavior upgrades. @@ -73,16 +151,17 @@ Read only what you need: ## Quality rules - Treat OpenAI docs as the source of truth; avoid speculation. +- For Codex self-knowledge, follow the source route above instead of relying on remembered behavior. - Keep migration changes narrow and behavior-preserving. - Prefer prompt-only upgrades when possible. -- Do not invent pricing, availability, parameters, API changes, or breaking changes. +- Avoid inventing pricing, availability, parameters, API changes, or breaking changes. - Keep quotes short and within policy limits; prefer paraphrase with citations. - If multiple pages differ, call out the difference and cite both. -- If official docs and repo behavior disagree, state the conflict and stop before making broad edits. +- If official docs and verified callable current-session behavior disagree, state the conflict before making broad claims or edits. - If docs do not cover the user’s need, say so and offer next steps. ## Tooling notes -- Always use MCP doc tools before any web search for OpenAI-related questions. +- Use MCP doc tools before web search for OpenAI-related markdown docs. The Codex manual flow is the exception: follow the Codex self-knowledge source procedure for broad Codex synthesis. - If the MCP server is installed but returns no meaningful results, then use web search as a fallback. - When falling back to web search, restrict to official OpenAI domains (developers.openai.com, platform.openai.com) and cite sources. diff --git a/codex-rs/skills/src/assets/samples/openai-docs/agents/openai.yaml b/codex-rs/skills/src/assets/samples/openai-docs/agents/openai.yaml index d056abcad7..8bbf03c206 100644 --- a/codex-rs/skills/src/assets/samples/openai-docs/agents/openai.yaml +++ b/codex-rs/skills/src/assets/samples/openai-docs/agents/openai.yaml @@ -1,9 +1,9 @@ interface: display_name: "OpenAI Docs" - short_description: "Reference docs, choose models, and migrate OpenAI API integrations" + short_description: "Reference OpenAI docs, Codex self-knowledge, and model migration guidance" icon_small: "./assets/openai-small.svg" icon_large: "./assets/openai.png" - default_prompt: "Use OpenAI Docs for official docs lookup, model selection, model migration, and prompt-upgrade work." + default_prompt: "Use OpenAI Docs for official docs lookup, questions about Codex itself or Codex surfaces, model selection, model migration, and prompt-upgrade work." dependencies: tools: diff --git a/codex-rs/skills/src/assets/samples/openai-docs/scripts/fetch-codex-manual.mjs b/codex-rs/skills/src/assets/samples/openai-docs/scripts/fetch-codex-manual.mjs new file mode 100644 index 0000000000..b2605520dc --- /dev/null +++ b/codex-rs/skills/src/assets/samples/openai-docs/scripts/fetch-codex-manual.mjs @@ -0,0 +1,598 @@ +#!/usr/bin/env node +import { + access, + mkdir, + readFile, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import { inspect, promisify } from "node:util"; + +const DEFAULT_MANUAL_URL = "https://developers.openai.com/codex/codex-manual.md"; +const DEFAULT_CACHE_DIR_NAME = "openai-docs-cache"; +const CACHE_FILE_NAME = "codex-manual.md"; +const OUTLINE_FILE_NAME = "codex-manual.outline.md"; +const HASH_HEADER = "x-content-sha256"; +const USER_AGENT = "codex-openai-docs"; +const execFileAsync = promisify(execFile); + +class ManualFetchError extends Error { + constructor(message, options) { + super(message, options); + this.name = "ManualFetchError"; + } +} + +const sha256 = (value) => createHash("sha256").update(value).digest("hex"); + +const withTimeout = async (promiseFactory, timeoutMs) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await promiseFactory(controller.signal); + } finally { + clearTimeout(timeout); + } +}; + +const proxyConfigured = () => + process.env.HTTP_PROXY || + process.env.HTTPS_PROXY || + process.env.http_proxy || + process.env.https_proxy; + +const responseHeaders = (headers) => ({ + get(name) { + return headers.get(name.toLowerCase()) ?? null; + }, +}); + +const makeResponse = ({ body, headers, status }) => ({ + headers: responseHeaders(headers), + ok: status >= 200 && status < 300, + status, + async text() { + return body; + }, +}); + +const parseCurlHeaders = (rawHeaders) => { + const normalized = rawHeaders.replace(/\r\n/g, "\n").trim(); + const blocks = normalized.split(/\n\n+/).filter(Boolean); + const headerBlock = [...blocks] + .reverse() + .find((block) => block.startsWith("HTTP/")); + + if (!headerBlock) { + throw new ManualFetchError("curl did not return HTTP response headers."); + } + + const [statusLine, ...lines] = headerBlock.split("\n"); + const statusMatch = /^HTTP\/\S+\s+(\d{3})/.exec(statusLine); + if (!statusMatch) { + throw new ManualFetchError( + `Could not parse HTTP status from curl response: ${statusLine}` + ); + } + + const headers = new Map(); + lines.forEach((line) => { + const separator = line.indexOf(":"); + if (separator === -1) return; + const name = line.slice(0, separator).trim().toLowerCase(); + const value = line.slice(separator + 1).trim(); + headers.set(name, value); + }); + + return { + headers, + status: Number(statusMatch[1]), + }; +}; + +const tempFilePath = (cacheDir, suffix) => + path.join( + cacheDir, + `.fetch-codex-manual-${process.pid}-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}${suffix}` + ); + +const requestManualWithCurl = async (url, { cacheDir, method, timeoutMs }) => { + const headerPath = tempFilePath(cacheDir, ".headers"); + const bodyPath = tempFilePath(cacheDir, ".body"); + const curlNames = + process.platform === "win32" ? ["curl.exe", "curl"] : ["curl"]; + const args = [ + "--silent", + "--show-error", + "--location", + "--dump-header", + headerPath, + "--output", + bodyPath, + "--user-agent", + USER_AGENT, + "--max-time", + String(Math.max(1, Math.ceil(timeoutMs / 1000))), + ]; + + if (method === "HEAD") { + args.push("--head"); + } else { + args.push("--request", method); + } + args.push(url); + + let lastError; + for (const curlName of curlNames) { + try { + await execFileAsync(curlName, args, { windowsHide: true }); + const [rawHeaders, body] = await Promise.all([ + readFile(headerPath, "utf8"), + readFile(bodyPath, "utf8"), + ]); + const { headers, status } = parseCurlHeaders(rawHeaders); + return makeResponse({ body, headers, status }); + } catch (error) { + lastError = error; + if (error?.code !== "ENOENT") break; + } finally { + await Promise.all([ + rm(headerPath, { force: true }), + rm(bodyPath, { force: true }), + ]); + } + } + + if (lastError?.code === "ENOENT") { + throw new ManualFetchError("curl is unavailable in this environment.", { + cause: lastError, + }); + } + throw new ManualFetchError(`${method} ${url} could not be fetched.`, { + cause: lastError, + }); +}; + +const requestManualWithFetch = async (url, { method, timeoutMs }) => { + if (typeof fetch !== "function") { + throw new ManualFetchError( + "Native fetch is unavailable in this Node runtime." + ); + } + + return withTimeout( + (signal) => + fetch(url, { + method, + headers: { "User-Agent": USER_AGENT }, + signal, + }), + timeoutMs + ); +}; + +const requestManual = async (url, { cacheDir, method, timeoutMs }) => { + const preferCurl = Boolean(proxyConfigured()) || typeof fetch !== "function"; + const transports = preferCurl + ? [ + () => requestManualWithCurl(url, { cacheDir, method, timeoutMs }), + () => requestManualWithFetch(url, { method, timeoutMs }), + ] + : [ + () => requestManualWithFetch(url, { method, timeoutMs }), + () => requestManualWithCurl(url, { cacheDir, method, timeoutMs }), + ]; + + let lastError; + for (const transport of transports) { + try { + const response = await transport(); + if (!response.ok) { + throw new ManualFetchError( + `${method} ${url} failed with HTTP ${response.status}.` + ); + } + return response; + } catch (error) { + lastError = error; + } + } + + throw new ManualFetchError(`${method} ${url} could not be fetched.`, { + cause: lastError, + }); +}; + +const readHeaderSha = (response) => { + const value = response.headers.get(HASH_HEADER); + if (!value || !/^[a-f0-9]{64}$/i.test(value)) { + throw new ManualFetchError(`Manual response is missing ${HASH_HEADER}.`); + } + return value.toLowerCase(); +}; + +const nearestExistingParent = async (target) => { + let current = target; + while (true) { + try { + const info = await stat(current); + return info.isDirectory() ? current : null; + } catch (error) { + if (error?.code !== "ENOENT") return null; + } + + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +}; + +const usableCacheDir = async (cacheDir) => { + if (!cacheDir) return null; + const resolved = path.resolve(cacheDir); + + try { + const info = await stat(resolved); + if (!info.isDirectory()) return null; + } catch (error) { + if (error?.code !== "ENOENT") return null; + } + + const parent = await nearestExistingParent(resolved); + if (!parent) return null; + + try { + await access(parent, fsConstants.W_OK | fsConstants.X_OK); + } catch { + return null; + } + + return resolved; +}; + +const defaultCacheDirCandidates = () => { + const candidates = []; + const seen = new Set(); + const pushCandidate = (candidate) => { + if (!candidate || seen.has(candidate)) return; + seen.add(candidate); + candidates.push(candidate); + }; + + [process.env.TMPDIR, process.env.TEMP, process.env.TMP].forEach((baseDir) => { + if (baseDir) { + pushCandidate(path.join(baseDir, DEFAULT_CACHE_DIR_NAME)); + } + }); + + if (process.platform !== "win32") { + pushCandidate(`/private/tmp/${DEFAULT_CACHE_DIR_NAME}`); + pushCandidate(`/tmp/${DEFAULT_CACHE_DIR_NAME}`); + } + + return candidates; +}; + +const resolveCacheDir = async (cacheDir) => { + if (cacheDir) { + return usableCacheDir(cacheDir); + } + + for (const candidate of defaultCacheDirCandidates()) { + const usable = await usableCacheDir(candidate); + if (usable) return usable; + } + + return null; +}; + +const cacheFilePath = (cacheDir) => path.join(cacheDir, CACHE_FILE_NAME); + +const outlineFilePath = (cacheDir) => path.join(cacheDir, OUTLINE_FILE_NAME); + +const manualLines = (manual) => { + const lines = manual.replace(/\r\n/g, "\n").split("\n"); + if (lines[lines.length - 1] === "") lines.pop(); + return lines; +}; + +const sectionTitle = (rawTitle) => + rawTitle.replace(/\s+#+\s*$/, "").replace(/\s+/g, " ").trim(); + +const buildOutline = (manual) => { + const lines = manualLines(manual); + const headings = []; + let inFence = false; + + lines.forEach((line, index) => { + if (/^\s*(```|~~~)/.test(line)) { + inFence = !inFence; + return; + } + if (inFence) return; + + const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line); + if (!match) return; + + const level = match[1].length; + if (level < 2 || level > 3) return; + + headings.push({ + level, + title: sectionTitle(match[2]), + startLine: index + 1, + endLine: lines.length, + }); + }); + + for (let index = 0; index < headings.length; index += 1) { + const heading = headings[index]; + const nextPeer = headings + .slice(index + 1) + .find((candidate) => candidate.level <= heading.level); + if (nextPeer) { + heading.endLine = nextPeer.startLine - 1; + } + } + + if (headings.length === 0) { + return { + headingCount: 0, + lineCount: lines.length, + text: "No markdown headings found.", + }; + } + + const minLevel = Math.min(...headings.map((heading) => heading.level)); + return { + headingCount: headings.length, + lineCount: lines.length, + text: headings + .map((heading) => { + const indent = " ".repeat(heading.level - minLevel); + return `${indent}- ${heading.title} (lines ${heading.startLine}-${heading.endLine})`; + }) + .join("\n"), + }; +}; + +const outlineMarkdown = (outline) => `# Codex Manual Outline\n\n${outline.text}\n`; + +const manualStatusLine = (status) => + status.cacheStatus === "hit" + ? "Manual status: local manual was already current." + : "Manual status: local manual was updated."; + +const formatResult = ({ status, outlineText }) => + [ + `Manual path: ${status.manualPath}`, + `Outline path: ${status.outlinePath}`, + manualStatusLine(status), + "", + outlineText, + ].join("\n"); + +const readCachedManual = async (cacheDir, expectedSha256) => { + try { + const manual = await readFile(cacheFilePath(cacheDir), "utf8"); + return sha256(manual) === expectedSha256 ? manual : null; + } catch { + return null; + } +}; + +const writeCachedManual = async (cacheDir, manual) => { + await mkdir(cacheDir, { recursive: true }); + const tmpPath = tempFilePath(cacheDir, `.${CACHE_FILE_NAME}.tmp`); + await writeFile(tmpPath, manual, "utf8"); + await rename(tmpPath, cacheFilePath(cacheDir)); +}; + +const writeOutline = async (cacheDir, outlineText) => { + await mkdir(cacheDir, { recursive: true }); + const tmpPath = tempFilePath(cacheDir, `.${OUTLINE_FILE_NAME}.tmp`); + await writeFile(tmpPath, outlineText, "utf8"); + await rename(tmpPath, outlineFilePath(cacheDir)); +}; + +const fetchCodexManual = async ({ + manualUrl = DEFAULT_MANUAL_URL, + cacheDir, + timeoutMs = 30000, +} = {}) => { + const resolvedCacheDir = await resolveCacheDir(cacheDir); + if (!resolvedCacheDir) { + throw new ManualFetchError( + "Manual cache directory is unavailable; pass --cache-dir to override or use OpenAI Docs MCP fallback." + ); + } + await mkdir(resolvedCacheDir, { recursive: true }); + + const headResponse = await requestManual(manualUrl, { + cacheDir: resolvedCacheDir, + method: "HEAD", + timeoutMs, + }); + const expectedSha256 = readHeaderSha(headResponse); + const manualPath = cacheFilePath(resolvedCacheDir); + const outlinePath = outlineFilePath(resolvedCacheDir); + const checkedAt = new Date().toISOString(); + + const cachedManual = await readCachedManual(resolvedCacheDir, expectedSha256); + if (cachedManual !== null) { + const outline = buildOutline(cachedManual); + const outlineText = outlineMarkdown(outline); + await writeOutline(resolvedCacheDir, outlineText); + + return { + outlineText, + status: { + manualUrl, + headerSha256: expectedSha256, + fetchedManualSha256: expectedSha256, + manualHashMatches: true, + cacheStatus: "hit", + cacheDir: resolvedCacheDir, + manualPath, + outlinePath, + checkedAt, + lineCount: outline.lineCount, + headingCount: outline.headingCount, + }, + }; + } + + const getResponse = await requestManual(manualUrl, { + cacheDir: resolvedCacheDir, + method: "GET", + timeoutMs, + }); + const getHeaderSha256 = readHeaderSha(getResponse); + if (getHeaderSha256 !== expectedSha256) { + throw new ManualFetchError( + `${HASH_HEADER} changed between HEAD and GET for ${manualUrl}.` + ); + } + + const manualText = await getResponse.text(); + const actualSha256 = sha256(manualText); + const manualHashMatches = actualSha256 === expectedSha256; + if (!manualHashMatches) { + throw new ManualFetchError( + `${HASH_HEADER} did not match the fetched manual body for ${manualUrl}.` + ); + } + + await writeCachedManual(resolvedCacheDir, manualText); + const outline = buildOutline(manualText); + const outlineText = outlineMarkdown(outline); + await writeOutline(resolvedCacheDir, outlineText); + + return { + outlineText, + status: { + manualUrl, + headerSha256: expectedSha256, + fetchedManualSha256: actualSha256, + manualHashMatches, + cacheStatus: "updated", + cacheDir: resolvedCacheDir, + manualPath, + outlinePath, + checkedAt, + lineCount: outline.lineCount, + headingCount: outline.headingCount, + }, + }; +}; + +const parseArgs = (argv) => { + const args = { + manualUrl: DEFAULT_MANUAL_URL, + cacheDir: undefined, + timeoutMs: 30000, + statusJson: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--manual-url") { + args.manualUrl = argv[++index]; + } else if (arg === "--cache-dir") { + args.cacheDir = argv[++index]; + } else if (arg === "--timeout-ms") { + args.timeoutMs = Number(argv[++index]); + } else if (arg === "--status-json") { + args.statusJson = true; + } else { + throw new ManualFetchError(`Unknown argument: ${arg}`); + } + } + + if (!args.manualUrl) { + throw new ManualFetchError("--manual-url cannot be empty."); + } + if (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0) { + throw new ManualFetchError("--timeout-ms must be a positive number."); + } + + return args; +}; + +const main = async () => { + const args = parseArgs(process.argv.slice(2)); + const { outlineText, status } = await fetchCodexManual(args); + + process.stdout.write(formatResult({ status, outlineText })); + + if (args.statusJson) { + console.error(JSON.stringify(status)); + } +}; + +const envProxyHint = () => { + if (proxyConfigured()) { + return "Hint: proxy env vars are present. This helper prefers `curl` in proxied sessions; if requests still fail, verify `curl` is installed and the proxy configuration is valid."; + } + if (typeof fetch !== "function") { + return "Hint: native fetch is unavailable in this Node runtime. Install `curl` or use a newer Node version to fetch the manual."; + } + if (process.platform === "win32") { + return "Hint: on Windows, pass a cache dir under `%TEMP%` or `%TMP%`."; + } + return null; +}; + +const formatErrorDetails = (error) => { + const details = inspect(error, { + breakLength: 120, + colors: false, + compact: false, + depth: 8, + }); + if (!error?.cause) { + return details; + } + + return `${details}\n\nCause:\n${inspect(error.cause, { + breakLength: 120, + colors: false, + compact: false, + depth: 8, + })}`; +}; + +const isCliEntrypoint = () => { + const entrypoint = process.argv[1]; + if (!entrypoint) { + return false; + } + + return pathToFileURL(entrypoint).href === import.meta.url; +}; + +if (isCliEntrypoint()) { + main().catch((error) => { + console.error(`Error: ${error.message}`); + const hint = envProxyHint(); + if (hint) { + console.error(hint); + } + console.error(""); + console.error("Details:"); + console.error(formatErrorDetails(error)); + process.exitCode = 1; + }); +} + +export { DEFAULT_MANUAL_URL, fetchCodexManual }; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 2369addf23..5f0734e330 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -200,6 +200,7 @@ mod tests { fn event_msg_user_messages_set_title_and_first_user_message() { let mut metadata = metadata_for_test(); let item = RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: format!("{USER_MESSAGE_BEGIN} actual user request"), images: Some(vec![]), local_images: vec![], @@ -221,6 +222,7 @@ mod tests { fn event_msg_image_only_user_message_sets_image_placeholder_preview() { let mut metadata = metadata_for_test(); let item = RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: String::new(), images: Some(vec!["https://example.com/image.png".to_string()]), local_images: vec![], @@ -245,6 +247,7 @@ mod tests { fn event_msg_blank_user_message_without_images_keeps_first_user_message_empty() { let mut metadata = metadata_for_test(); let item = RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: " ".to_string(), images: Some(vec![]), local_images: vec![], @@ -285,6 +288,7 @@ mod tests { assert_eq!(metadata.title, ""); let user_item = RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: format!("{USER_MESSAGE_BEGIN} next normal prompt"), images: Some(vec![]), local_images: vec![], @@ -339,6 +343,7 @@ mod tests { &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), cwd: PathBuf::from("/parent/workspace"), + workspace_roots: None, current_date: None, timezone: None, approval_policy: AskForApproval::Never, @@ -374,6 +379,7 @@ mod tests { &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), cwd: PathBuf::from("/fallback/workspace"), + workspace_roots: None, current_date: None, timezone: None, approval_policy: AskForApproval::OnRequest, @@ -403,6 +409,7 @@ mod tests { &RolloutItem::TurnContext(TurnContextItem { turn_id: Some("turn-1".to_string()), cwd: PathBuf::from("/fallback/workspace"), + workspace_roots: None, current_date: None, timezone: None, approval_policy: AskForApproval::OnRequest, diff --git a/codex-rs/state/src/migrations.rs b/codex-rs/state/src/migrations.rs index 641d584530..0e12f17c45 100644 --- a/codex-rs/state/src/migrations.rs +++ b/codex-rs/state/src/migrations.rs @@ -19,6 +19,8 @@ fn runtime_migrator(base: &'static Migrator) -> Migrator { ignore_missing: true, locking: base.locking, no_tx: base.no_tx, + table_name: base.table_name.clone(), + create_schemas: base.create_schemas.clone(), } } diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index ba41021290..b08545d7a6 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -33,7 +33,6 @@ use crate::telemetry::DbTelemetry; use chrono::DateTime; use chrono::Utc; use codex_protocol::ThreadId; -use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::protocol::RolloutItem; use log::LevelFilter; use serde_json::Value; diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs index 38a64d0845..aa0e90462b 100644 --- a/codex-rs/state/src/runtime/goals.rs +++ b/codex-rs/state/src/runtime/goals.rs @@ -399,44 +399,78 @@ WHERE thread_id = ? } let now_ms = datetime_to_epoch_millis(Utc::now()); + let active_or_stopped_status_filter = + "status IN ('active', 'paused', 'blocked', 'usage_limited', 'budget_limited')"; let status_filter = match mode { GoalAccountingMode::ActiveStatusOnly => "status = 'active'", GoalAccountingMode::ActiveOnly => "status IN ('active', 'budget_limited')", GoalAccountingMode::ActiveOrComplete => { "status IN ('active', 'budget_limited', 'complete')" } - GoalAccountingMode::ActiveOrStopped => { - "status IN ('active', 'paused', 'blocked', 'usage_limited', 'budget_limited')" - } + GoalAccountingMode::ActiveOrStopped => active_or_stopped_status_filter, }; let budget_limit_status_filter = match mode { GoalAccountingMode::ActiveStatusOnly | GoalAccountingMode::ActiveOnly | GoalAccountingMode::ActiveOrComplete => "status = 'active'", - GoalAccountingMode::ActiveOrStopped => { - "status IN ('active', 'paused', 'blocked', 'usage_limited', 'budget_limited')" - } + GoalAccountingMode::ActiveOrStopped => active_or_stopped_status_filter, }; - let goal_id_filter = if expected_goal_id.is_some() { - "goal_id = ?" - } else { - "1 = 1" - }; - let query = format!( + let mut builder = QueryBuilder::::new( r#" UPDATE thread_goals SET - time_used_seconds = time_used_seconds + ?, - tokens_used = tokens_used + ?, + time_used_seconds = time_used_seconds + + "#, + ); + builder.push_bind(time_delta_seconds); + builder.push( + r#", + tokens_used = tokens_used + + "#, + ); + builder.push_bind(token_delta); + builder.push( + r#", status = CASE - WHEN {budget_limit_status_filter} AND token_budget IS NOT NULL AND tokens_used + ? >= token_budget - THEN ? + WHEN + "#, + ); + builder.push(budget_limit_status_filter); + builder.push( + r#" + AND token_budget IS NOT NULL + AND tokens_used + + "#, + ); + builder.push_bind(token_delta); + builder.push( + r#" + >= token_budget + THEN + "#, + ); + builder.push_bind(crate::ThreadGoalStatus::BudgetLimited.as_str()); + builder.push( + r#" ELSE status END, - updated_at_ms = ? -WHERE thread_id = ? - AND {status_filter} - AND {goal_id_filter} + updated_at_ms = + "#, + ); + builder.push_bind(now_ms); + builder.push( + r#" +WHERE thread_id = + "#, + ); + builder.push_bind(thread_id.to_string()); + builder.push(" AND "); + builder.push(status_filter); + if let Some(expected_goal_id) = expected_goal_id { + builder.push(" AND goal_id = ").push_bind(expected_goal_id); + } + builder.push( + r#" RETURNING thread_id, goal_id, @@ -450,18 +484,7 @@ RETURNING "#, ); - let mut query = sqlx::query(&query) - .bind(time_delta_seconds) - .bind(token_delta) - .bind(token_delta) - .bind(crate::ThreadGoalStatus::BudgetLimited.as_str()) - .bind(now_ms) - .bind(thread_id.to_string()); - if let Some(expected_goal_id) = expected_goal_id { - query = query.bind(expected_goal_id); - } - - let row = query.fetch_optional(self.pool.as_ref()).await?; + let row = builder.build().fetch_optional(self.pool.as_ref()).await?; let Some(row) = row else { return Ok(GoalAccountingOutcome::Unchanged( diff --git a/codex-rs/state/src/runtime/logs.rs b/codex-rs/state/src/runtime/logs.rs index e21df25e90..75b9ce82b2 100644 --- a/codex-rs/state/src/runtime/logs.rs +++ b/codex-rs/state/src/runtime/logs.rs @@ -343,11 +343,23 @@ WHERE id IN ( let max_bytes = usize::try_from(LOG_PARTITION_SIZE_LIMIT_BYTES).unwrap_or(usize::MAX); // Bound the fetched rows in SQL first so over-retained partitions do not have to load // every row into memory, then apply the exact whole-line byte cap after formatting. - let requested_threads = vec!["(?)"; thread_ids.len()].join(", "); - let query = format!( + let mut builder = QueryBuilder::::new( r#" WITH requested_threads(thread_id) AS ( - VALUES {requested_threads} + VALUES + "#, + ); + { + let mut separated = builder.separated(", "); + for thread_id in thread_ids { + separated + .push("(") + .push_bind_unseparated(*thread_id) + .push_unseparated(")"); + } + } + builder.push( + r#" ), latest_processes AS ( SELECT ( @@ -388,16 +400,13 @@ bounded_feedback_logs AS ( ) SELECT ts, ts_nanos, level, feedback_log_body FROM bounded_feedback_logs -WHERE cumulative_estimated_bytes <= ? -ORDER BY ts DESC, ts_nanos DESC, id DESC -"# +WHERE cumulative_estimated_bytes <= +"#, ); - let mut sql = sqlx::query_as::<_, FeedbackLogRow>(query.as_str()); - for thread_id in thread_ids { - sql = sql.bind(thread_id); - } - let rows = sql - .bind(LOG_PARTITION_SIZE_LIMIT_BYTES) + builder.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES); + builder.push(" ORDER BY ts DESC, ts_nanos DESC, id DESC"); + let rows = builder + .build_query_as::() .fetch_all(self.logs_pool.as_ref()) .await?; @@ -463,7 +472,7 @@ fn format_feedback_log_line( line } -fn push_log_filters<'a>(builder: &mut QueryBuilder<'a, Sqlite>, query: &'a LogQuery) { +fn push_log_filters(builder: &mut QueryBuilder, query: &LogQuery) { if !query.levels_upper.is_empty() { builder.push(" AND UPPER(level) IN ("); { @@ -511,11 +520,7 @@ fn push_log_filters<'a>(builder: &mut QueryBuilder<'a, Sqlite>, query: &'a LogQu } } -fn push_like_filters<'a>( - builder: &mut QueryBuilder<'a, Sqlite>, - column: &str, - filters: &'a [String], -) { +fn push_like_filters(builder: &mut QueryBuilder, column: &str, filters: &[String]) { if filters.is_empty() { return; } @@ -613,6 +618,8 @@ mod tests { ignore_missing: false, locking: true, no_tx: false, + table_name: LOGS_MIGRATOR.table_name.clone(), + create_schemas: LOGS_MIGRATOR.create_schemas.clone(), }; let pool = SqlitePool::connect_with( SqliteConnectOptions::new() diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index 350d22cc6d..3101500796 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -511,7 +511,7 @@ WHERE so.thread_id = ? AND so.source_updated_at = ? } } - selected.sort_by(|a, b| a.thread_id.to_string().cmp(&b.thread_id.to_string())); + selected.sort_by_key(|entry| entry.thread_id.to_string()); Ok(selected) } diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 78180e7c7d..c67056ab54 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -74,40 +74,6 @@ WHERE id = ? AND preview = '' Ok(result.rows_affected() > 0) } - /// Get dynamic tools for a thread, if present. - pub async fn get_dynamic_tools( - &self, - thread_id: ThreadId, - ) -> anyhow::Result>> { - let rows = sqlx::query( - r#" -SELECT namespace, name, description, input_schema, defer_loading -FROM thread_dynamic_tools -WHERE thread_id = ? -ORDER BY position ASC - "#, - ) - .bind(thread_id.to_string()) - .fetch_all(self.pool.as_ref()) - .await?; - if rows.is_empty() { - return Ok(None); - } - let mut tools = Vec::with_capacity(rows.len()); - for row in rows { - let input_schema: String = row.try_get("input_schema")?; - let input_schema = serde_json::from_str::(input_schema.as_str())?; - tools.push(DynamicToolSpec { - namespace: row.try_get("namespace")?, - name: row.try_get("name")?, - description: row.try_get("description")?, - input_schema, - defer_loading: row.try_get("defer_loading")?, - }); - } - Ok(Some(tools)) - } - /// Persist or replace the directional parent-child edge for a spawned thread. pub async fn upsert_thread_spawn_edge( &self, @@ -252,20 +218,16 @@ LIMIT 2 parent_thread_id: ThreadId, status: Option, ) -> anyhow::Result> { - let mut query = String::from( - "SELECT child_thread_id FROM thread_spawn_edges WHERE parent_thread_id = ?", + let mut builder = QueryBuilder::::new( + "SELECT child_thread_id FROM thread_spawn_edges WHERE parent_thread_id = ", ); - if status.is_some() { - query.push_str(" AND status = ?"); - } - query.push_str(" ORDER BY child_thread_id"); - - let mut sql = sqlx::query(query.as_str()).bind(parent_thread_id.to_string()); + builder.push_bind(parent_thread_id.to_string()); if let Some(status) = status { - sql = sql.bind(status.to_string()); + builder.push(" AND status = ").push_bind(status.to_string()); } + builder.push(" ORDER BY child_thread_id"); - let rows = sql.fetch_all(self.pool.as_ref()).await?; + let rows = builder.build().fetch_all(self.pool.as_ref()).await?; rows.into_iter() .map(|row| { ThreadId::try_from(row.try_get::("child_thread_id")?).map_err(Into::into) @@ -278,36 +240,48 @@ LIMIT 2 root_thread_id: ThreadId, status: Option, ) -> anyhow::Result> { - let status_filter = if status.is_some() { - " AND status = ?" - } else { - "" - }; - let query = format!( + let mut builder = QueryBuilder::::new( r#" WITH RECURSIVE subtree(child_thread_id, depth) AS ( SELECT child_thread_id, 1 FROM thread_spawn_edges - WHERE parent_thread_id = ?{status_filter} + WHERE parent_thread_id = + "#, + ); + builder.push_bind(root_thread_id.to_string()); + if let Some(status) = status { + let status = status.to_string(); + builder.push(" AND status = ").push_bind(status.clone()); + builder.push( + r#" UNION ALL SELECT edge.child_thread_id, subtree.depth + 1 FROM thread_spawn_edges AS edge JOIN subtree ON edge.parent_thread_id = subtree.child_thread_id - WHERE 1 = 1{status_filter} + WHERE status = + "#, + ); + builder.push_bind(status); + } else { + builder.push( + r#" + UNION ALL + SELECT edge.child_thread_id, subtree.depth + 1 + FROM thread_spawn_edges AS edge + JOIN subtree ON edge.parent_thread_id = subtree.child_thread_id + "#, + ); + } + builder.push( + r#" ) SELECT child_thread_id FROM subtree ORDER BY depth ASC, child_thread_id ASC - "# + "#, ); - let mut sql = sqlx::query(query.as_str()).bind(root_thread_id.to_string()); - if let Some(status) = status { - let status = status.to_string(); - sql = sql.bind(status.clone()).bind(status); - } - - let rows = sql.fetch_all(self.pool.as_ref()).await?; + let rows = builder.build().fetch_all(self.pool.as_ref()).await?; rows.into_iter() .map(|row| { ThreadId::try_from(row.try_get::("child_thread_id")?).map_err(Into::into) @@ -813,54 +787,6 @@ ON CONFLICT(id) DO UPDATE SET Ok(()) } - /// Persist dynamic tools for a thread if none have been stored yet. - /// - /// Dynamic tools are defined at thread start and should not change afterward. - /// This only writes the first time we see tools for a given thread. - pub async fn persist_dynamic_tools( - &self, - thread_id: ThreadId, - tools: Option<&[DynamicToolSpec]>, - ) -> anyhow::Result<()> { - let Some(tools) = tools else { - return Ok(()); - }; - if tools.is_empty() { - return Ok(()); - } - let thread_id = thread_id.to_string(); - let mut tx = self.pool.begin().await?; - for (idx, tool) in tools.iter().enumerate() { - let position = i64::try_from(idx).unwrap_or(i64::MAX); - let input_schema = serde_json::to_string(&tool.input_schema)?; - sqlx::query( - r#" -INSERT INTO thread_dynamic_tools ( - thread_id, - position, - namespace, - name, - description, - input_schema, - defer_loading -) VALUES (?, ?, ?, ?, ?, ?, ?) -ON CONFLICT(thread_id, position) DO NOTHING - "#, - ) - .bind(thread_id.as_str()) - .bind(position) - .bind(tool.namespace.as_deref()) - .bind(tool.name.as_str()) - .bind(tool.description.as_str()) - .bind(input_schema) - .bind(tool.defer_loading) - .execute(&mut *tx) - .await?; - } - tx.commit().await?; - Ok(()) - } - /// Apply rollout items incrementally using the underlying database. pub async fn apply_rollout_items( &self, @@ -890,8 +816,6 @@ ON CONFLICT(thread_id, position) DO NOTHING if let Some(updated_at) = updated_at { metadata.updated_at = updated_at; } - // Keep the thread upsert before dynamic tools to satisfy the foreign key constraint: - // thread_dynamic_tools.thread_id -> threads.id. let upsert_result = if existing_metadata.is_none() { self.upsert_thread_with_creation_memory_mode(&metadata, new_thread_memory_mode) .await @@ -906,14 +830,6 @@ ON CONFLICT(thread_id, position) DO NOTHING { return Err(err); } - let dynamic_tools = extract_dynamic_tools(items); - if let Some(dynamic_tools) = dynamic_tools - && let Err(err) = self - .persist_dynamic_tools(builder.id, dynamic_tools.as_deref()) - .await - { - return Err(err); - } Ok(()) } @@ -999,7 +915,7 @@ fn one_thread_id_from_rows( } } -pub(super) fn push_thread_select_columns(builder: &mut QueryBuilder<'_, Sqlite>) { +pub(super) fn push_thread_select_columns(builder: &mut QueryBuilder) { builder.push( r#" SELECT @@ -1031,16 +947,6 @@ SELECT ); } -pub(super) fn extract_dynamic_tools(items: &[RolloutItem]) -> Option>> { - items.iter().find_map(|item| match item { - RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.dynamic_tools.clone()), - RolloutItem::ResponseItem(_) - | RolloutItem::Compacted(_) - | RolloutItem::TurnContext(_) - | RolloutItem::EventMsg(_) => None, - }) -} - pub(super) fn extract_memory_mode(items: &[RolloutItem]) -> Option { items.iter().rev().find_map(|item| match item { RolloutItem::SessionMeta(meta_line) => meta_line.meta.memory_mode.clone(), @@ -1076,7 +982,7 @@ pub struct ThreadFilterOptions<'a> { } pub(super) fn push_thread_filters<'a>( - builder: &mut QueryBuilder<'a, Sqlite>, + builder: &mut QueryBuilder, options: ThreadFilterOptions<'a>, ) { let ThreadFilterOptions { @@ -1156,7 +1062,7 @@ pub(super) fn push_thread_filters<'a>( } pub(super) fn push_thread_order_and_limit( - builder: &mut QueryBuilder<'_, Sqlite>, + builder: &mut QueryBuilder, sort_key: SortKey, sort_direction: SortDirection, limit: usize, diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index a6245796a3..7cb2ff827f 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -1042,6 +1042,7 @@ mod tests { fn user_message_item(message: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: message.to_string(), images: None, local_images: Vec::new(), diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 16f9f79e06..612328f4c7 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -309,14 +309,6 @@ async fn apply_metadata_update( message: format!("failed to update memory mode for {thread_id}: {err}"), })?; } - if let Some(dynamic_tools) = patch.dynamic_tools { - state_db - .persist_dynamic_tools(thread_id, Some(dynamic_tools.as_slice())) - .await - .map_err(|err| ThreadStoreError::Internal { - message: format!("failed to update dynamic tools for {thread_id}: {err}"), - })?; - } Ok(()) } .await @@ -393,7 +385,6 @@ fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool { || patch.sandbox_policy.is_some() || patch.token_usage.is_some() || patch.first_user_message.is_some() - || patch.dynamic_tools.is_some() } fn enum_to_string(value: &T) -> String { diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index edc78a36c2..d25a0e65a2 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -61,8 +61,6 @@ impl ThreadMetadataSync { } else { None }; - let dynamic_tools = - (!params.dynamic_tools.is_empty()).then(|| params.dynamic_tools.clone()); let update = ThreadMetadataPatch { model_provider: Some(params.metadata.model_provider.clone()), created_at: Some(created_at), @@ -76,7 +74,6 @@ impl ThreadMetadataSync { cli_version: Some(env!("CARGO_PKG_VERSION").to_string()), git_info: git_info.map(git_info_patch_from_observation), memory_mode: Some(params.metadata.memory_mode), - dynamic_tools, ..Default::default() }; Self { @@ -228,9 +225,6 @@ impl ThreadMetadataSync { { update.memory_mode = Some(memory_mode); } - if let Some(dynamic_tools) = meta_line.meta.dynamic_tools.clone() { - update.dynamic_tools = Some(dynamic_tools); - } } RolloutItem::TurnContext(turn_ctx) => { if !self.cwd_seen && !turn_ctx.cwd.as_os_str().is_empty() { @@ -365,7 +359,6 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool { || update.first_user_message.is_some() || update.git_info.is_some() || update.memory_mode.is_some() - || update.dynamic_tools.is_some() } fn git_info_patch_from_observation(git_info: GitInfo) -> GitInfoPatch { @@ -542,6 +535,7 @@ mod tests { fn user_message(message: &str) -> UserMessageEvent { UserMessageEvent { + client_id: None, message: message.to_string(), images: None, local_images: Vec::new(), diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index ca75ca1b69..1e26e5dc77 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -529,8 +529,6 @@ pub struct ThreadMetadataPatch { pub git_info: Option, /// Thread memory behavior. pub memory_mode: Option, - /// Dynamic tools available to this thread. - pub dynamic_tools: Option>, } impl ThreadMetadataPatch { @@ -608,9 +606,6 @@ impl ThreadMetadataPatch { if next.memory_mode.is_some() { self.memory_mode = next.memory_mode; } - if next.dynamic_tools.is_some() { - self.dynamic_tools = next.dynamic_tools; - } } pub fn is_empty(&self) -> bool { @@ -636,7 +631,6 @@ impl ThreadMetadataPatch { && self.first_user_message.is_none() && self.git_info.is_none() && self.memory_mode.is_none() - && self.dynamic_tools.is_none() } } diff --git a/codex-rs/tools/src/image_detail.rs b/codex-rs/tools/src/image_detail.rs index 145cda663c..37086f691d 100644 --- a/codex-rs/tools/src/image_detail.rs +++ b/codex-rs/tools/src/image_detail.rs @@ -16,7 +16,7 @@ pub fn normalize_output_image_detail( Some(ImageDetail::Original) } Some(ImageDetail::Original) | None => None, - Some(ImageDetail::High) => Some(ImageDetail::High), + Some(ImageDetail::Auto | ImageDetail::Low | ImageDetail::High) => detail, } } diff --git a/codex-rs/tools/src/image_detail_tests.rs b/codex-rs/tools/src/image_detail_tests.rs index 919537acf7..2e46b49d08 100644 --- a/codex-rs/tools/src/image_detail_tests.rs +++ b/codex-rs/tools/src/image_detail_tests.rs @@ -70,6 +70,14 @@ fn explicit_original_is_dropped_without_model_support() { fn explicit_non_original_detail_is_preserved() { let model_info = model_info(); + assert_eq!( + normalize_output_image_detail(&model_info, Some(ImageDetail::Auto)), + Some(ImageDetail::Auto) + ); + assert_eq!( + normalize_output_image_detail(&model_info, Some(ImageDetail::Low)), + Some(ImageDetail::Low) + ); assert_eq!( normalize_output_image_detail(&model_info, Some(ImageDetail::High)), Some(ImageDetail::High) @@ -88,7 +96,7 @@ fn sanitize_original_falls_back_to_high_without_support() { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,BBB".to_string(), - detail: Some(ImageDetail::High), + detail: Some(ImageDetail::Low), }, ]; @@ -106,7 +114,7 @@ fn sanitize_original_falls_back_to_high_without_support() { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,BBB".to_string(), - detail: Some(ImageDetail::High), + detail: Some(ImageDetail::Low), }, ] ); diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index 02936d52d1..1b53ad9803 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -157,10 +157,26 @@ impl From for AdditionalProperties { /// Parse the tool `input_schema` or return an error for invalid schema. pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result { + let mut input_schema = prepare_tool_input_schema(input_schema); + compact_large_tool_schema(&mut input_schema); + deserialize_tool_input_schema(input_schema) +} + +/// Parse a trusted tool `input_schema` without running large-schema compaction. +pub fn parse_tool_input_schema_without_compaction( + input_schema: &JsonValue, +) -> Result { + deserialize_tool_input_schema(prepare_tool_input_schema(input_schema)) +} + +fn prepare_tool_input_schema(input_schema: &JsonValue) -> JsonValue { 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); + input_schema +} + +fn deserialize_tool_input_schema(input_schema: JsonValue) -> Result { let schema: JsonSchema = serde_json::from_value(input_schema)?; if matches!( schema.schema_type, diff --git a/codex-rs/tools/src/json_schema_tests.rs b/codex-rs/tools/src/json_schema_tests.rs index 52b30138c1..9315d43c00 100644 --- a/codex-rs/tools/src/json_schema_tests.rs +++ b/codex-rs/tools/src/json_schema_tests.rs @@ -3,6 +3,7 @@ use super::JsonSchema; use super::JsonSchemaPrimitiveType; use super::JsonSchemaType; use super::parse_tool_input_schema; +use super::parse_tool_input_schema_without_compaction; use pretty_assertions::assert_eq; use std::collections::BTreeMap; @@ -791,8 +792,8 @@ fn many_string_properties(count: usize) -> serde_json::Map rmcp::model::Tool { - rmcp::model::Tool { - name: name.to_string().into(), - title: None, - description: Some(description.to_string().into()), - input_schema: std::sync::Arc::new(rmcp::model::object(input_schema)), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - } + rmcp::model::Tool::new( + name.to_string(), + description.to_string(), + std::sync::Arc::new(rmcp::model::object(input_schema)), + ) } #[test] diff --git a/codex-rs/tools/src/responses_api_tests.rs b/codex-rs/tools/src/responses_api_tests.rs index 3ce13ebfe8..e549d9969f 100644 --- a/codex-rs/tools/src/responses_api_tests.rs +++ b/codex-rs/tools/src/responses_api_tests.rs @@ -87,11 +87,10 @@ fn dynamic_tool_to_responses_api_tool_preserves_defer_loading() { #[test] fn mcp_tool_to_deferred_responses_api_tool_sets_defer_loading() { - let tool = rmcp::model::Tool { - name: "lookup_order".to_string().into(), - title: None, - description: Some("Look up an order".to_string().into()), - input_schema: std::sync::Arc::new(rmcp::model::object(json!({ + let tool = rmcp::model::Tool::new( + "lookup_order", + "Look up an order", + std::sync::Arc::new(rmcp::model::object(json!({ "type": "object", "properties": { "order_id": {"type": "string"} @@ -99,12 +98,7 @@ fn mcp_tool_to_deferred_responses_api_tool_sets_defer_loading() { "required": ["order_id"], "additionalProperties": false, }))), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }; + ); assert_eq!( mcp_tool_to_deferred_responses_api_tool( diff --git a/codex-rs/tools/src/tool_call.rs b/codex-rs/tools/src/tool_call.rs index 32d428648f..3c04e216a6 100644 --- a/codex-rs/tools/src/tool_call.rs +++ b/codex-rs/tools/src/tool_call.rs @@ -1,8 +1,11 @@ use crate::FunctionCallError; use crate::ToolName; use crate::ToolPayload; +use codex_protocol::items::WebSearchItem; use codex_protocol::models::ResponseItem; use codex_utils_output_truncation::TruncationPolicy; +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; /// Raw response history snapshot available when an extension tool is invoked. @@ -23,17 +26,70 @@ impl ConversationHistory { } } +/// Future returned when an extension tool emits a visible turn-item lifecycle event. +pub type TurnItemEmissionFuture<'a> = Pin + Send + 'a>>; + +/// Visible turn items that an extension fully owns and may emit as-is. +/// +/// Add only item kinds that require no additional host finalization before +/// persistence or client delivery. Richer items need a host-owned publish path. +#[derive(Clone, Debug, PartialEq)] +pub enum ExtensionTurnItem { + WebSearch(WebSearchItem), +} + +/// Host-provided capability for extension tools to emit finalized visible turn items. +/// +/// Implementations route lifecycle events through the host's normal item event +/// pipeline, including any persistence and client delivery owned by the host. +pub trait TurnItemEmitter: Send + Sync { + /// Emits the beginning of one visible turn item. + fn emit_started<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a>; + + /// Emits the completion of one visible turn item. + fn emit_completed<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a>; +} + +/// Turn-item emitter used when a caller does not expose visible item emission. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopTurnItemEmitter; + +impl TurnItemEmitter for NoopTurnItemEmitter { + fn emit_started<'a>(&'a self, _item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a> { + Box::pin(std::future::ready(())) + } + + fn emit_completed<'a>(&'a self, _item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a> { + Box::pin(std::future::ready(())) + } +} + // TODO: this is temporary and will disappear in the next PR (as we make codex-extension-api generic on Invocation. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct ToolCall { pub turn_id: String, pub call_id: String, pub tool_name: ToolName, pub truncation_policy: TruncationPolicy, pub conversation_history: ConversationHistory, + pub turn_item_emitter: Arc, pub payload: ToolPayload, } +impl std::fmt::Debug for ToolCall { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ToolCall") + .field("turn_id", &self.turn_id) + .field("call_id", &self.call_id) + .field("tool_name", &self.tool_name) + .field("truncation_policy", &self.truncation_policy) + .field("conversation_history", &self.conversation_history) + .field("turn_item_emitter", &"") + .field("payload", &self.payload) + .finish() + } +} + impl ToolCall { pub fn function_arguments(&self) -> Result<&str, FunctionCallError> { match &self.payload { diff --git a/codex-rs/tools/tests/json_schema_policy_fixtures.rs b/codex-rs/tools/tests/json_schema_policy_fixtures.rs index 1e244ade1e..71ea94a5d9 100644 --- a/codex-rs/tools/tests/json_schema_policy_fixtures.rs +++ b/codex-rs/tools/tests/json_schema_policy_fixtures.rs @@ -201,17 +201,11 @@ fn convert_fixture_tool( .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, - }; + let tool = rmcp::model::Tool::new( + name.to_string(), + fixture_tool.description.clone(), + Arc::new(input_schema), + ); mcp_tool_to_responses_api_tool(&ToolName::namespaced(&fixture.source, name), &tool) .unwrap_or_else(|err| panic!("convert {name} from {}: {err}", fixture.source)) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8c89c9153b..bd0cc45548 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -473,7 +473,7 @@ struct SessionSummary { #[derive(Debug, Default)] struct InitialHistoryReplayBuffer { - retained_lines: VecDeque>, + retained_lines: VecDeque, render_from_transcript_tail: bool, } @@ -498,7 +498,7 @@ pub(crate) struct App { // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, - pub(crate) deferred_history_lines: Vec>, + pub(crate) deferred_history_lines: Vec, has_emitted_history_lines: bool, transcript_reflow: TranscriptReflowState, initial_history_replay_buffer: Option, diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index f81f8b071e..d90f511d6f 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -400,16 +400,16 @@ impl App { } pub(super) fn refresh_plugin_mentions(&mut self, app_server: &AppServerSession) { - let config = self.config.clone(); + let cwd = self.config.cwd.to_path_buf(); let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); - if !config.features.enabled(Feature::Plugins) { + if !self.config.features.enabled(Feature::Plugins) { app_event_tx.send(AppEvent::PluginMentionsLoaded { plugins: None }); return; } tokio::spawn(async move { - match fetch_plugin_mentions(request_handle, config).await { + match fetch_plugin_mentions(request_handle, cwd).await { Ok(plugins) => { app_event_tx.send(AppEvent::PluginMentionsLoaded { plugins: Some(plugins), @@ -1061,6 +1061,7 @@ mod tests { let statuses = vec![ McpServerStatus { name: "docs".to_string(), + server_info: None, tools: HashMap::from([( "list".to_string(), Tool { @@ -1080,6 +1081,7 @@ mod tests { }, McpServerStatus { name: "disabled".to_string(), + server_info: None, tools: HashMap::new(), resources: Vec::new(), resource_templates: Vec::new(), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index f92806b162..5af2c49233 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -506,9 +506,6 @@ impl App { self.chat_widget .on_marketplace_add_loaded(cwd.clone(), source, result); if add_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!(error = %err, "failed to refresh config after marketplace add"); - } self.fetch_plugins_list(app_server, cwd); } } @@ -516,14 +513,7 @@ impl App { let marketplace_contents_changed = matches!(&result, Ok(response) if !response.upgraded_roots.is_empty()); if marketplace_contents_changed { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!( - error = %err, - "failed to refresh config after marketplace upgrade" - ); - } - self.chat_widget.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.refresh_plugin_mentions_after_config_write(); } self.chat_widget .on_marketplace_upgrade_loaded(cwd.clone(), result); @@ -558,11 +548,7 @@ impl App { ); if remove_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!(error = %err, "failed to refresh config after marketplace remove"); - } - self.chat_widget.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.refresh_plugin_mentions_after_config_write(); self.fetch_plugins_list(app_server, cwd); } } @@ -609,11 +595,7 @@ impl App { } => { let install_succeeded = result.is_ok(); if install_succeeded { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!(error = %err, "failed to refresh config after plugin install"); - } - self.chat_widget.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.refresh_plugin_mentions_after_config_write(); } let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded( cwd.clone(), @@ -665,14 +647,7 @@ impl App { self.pending_plugin_enabled_writes.remove(&plugin_id); let update_succeeded = result.is_ok(); if update_succeeded { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!( - error = %err, - "failed to refresh config after plugin toggle" - ); - } - self.chat_widget.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.refresh_plugin_mentions_after_config_write(); } self.chat_widget .on_plugin_enabled_set(cwd, plugin_id, enabled, result); @@ -907,8 +882,8 @@ impl App { return Ok(AppRunControl::Continue); } }; - let policy_cwd = self.config.cwd.clone(); - let command_cwd = policy_cwd.clone(); + let permission_profile_cwd = self.config.cwd.clone(); + let command_cwd = permission_profile_cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); let codex_home = self.config.codex_home.clone(); @@ -930,25 +905,10 @@ impl App { self.chat_widget.show_windows_sandbox_setup_status(); self.windows_sandbox.setup_started_at = Some(Instant::now()); let session_telemetry = self.session_telemetry.clone(); - let Ok(policy) = permission_profile - .to_legacy_sandbox_policy(policy_cwd.as_path()) - .inspect_err(|err| { - tracing::error!( - %err, - "approval preset permissions cannot be projected for elevated Windows sandbox setup" - ); - }) - else { - tx.send(AppEvent::OpenWindowsSandboxFallbackPrompt { - preset, - profile_selection, - }); - return Ok(AppRunControl::Continue); - }; tokio::task::spawn_blocking(move || { let result = crate::legacy_core::windows_sandbox::run_elevated_setup( - &policy, - policy_cwd.as_path(), + &permission_profile, + permission_profile_cwd.as_path(), command_cwd.as_path(), &env_map, codex_home.as_path(), @@ -1031,8 +991,8 @@ impl App { return Ok(AppRunControl::Continue); } }; - let policy_cwd = self.config.cwd.clone(); - let command_cwd = policy_cwd.clone(); + let permission_profile_cwd = self.config.cwd.clone(); + let command_cwd = permission_profile_cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); let codex_home = self.config.codex_home.clone(); @@ -1040,26 +1000,11 @@ impl App { let session_telemetry = self.session_telemetry.clone(); self.chat_widget.show_windows_sandbox_setup_status(); - let Ok(policy) = permission_profile - .to_legacy_sandbox_policy(policy_cwd.as_path()) - .inspect_err(|err| { - tracing::error!( - %err, - "approval preset permissions cannot be projected for legacy Windows sandbox setup" - ); - }) - else { - tx.send(AppEvent::OpenWindowsSandboxFallbackPrompt { - preset, - profile_selection, - }); - return Ok(AppRunControl::Continue); - }; tokio::task::spawn_blocking(move || { if let Err(err) = crate::legacy_core::windows_sandbox::run_legacy_setup_preflight( - &policy, - policy_cwd.as_path(), + &permission_profile, + permission_profile_cwd.as_path(), command_cwd.as_path(), &env_map, codex_home.as_path(), @@ -1096,11 +1041,8 @@ impl App { /*hint*/ None, )); - let policy = self - .config - .permissions - .legacy_sandbox_policy(self.config.cwd.as_path()); - let policy_cwd = self.config.cwd.clone(); + let permission_profile = self.config.permissions.effective_permission_profile(); + let permission_profile_cwd = self.config.cwd.clone(); let command_cwd = self.config.cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); @@ -1110,8 +1052,8 @@ impl App { tokio::task::spawn_blocking(move || { let requested_path = PathBuf::from(path); let event = match crate::legacy_core::grant_read_root_non_elevated( - &policy, - policy_cwd.as_path(), + &permission_profile, + permission_profile_cwd.as_path(), command_cwd.as_path(), &env_map, codex_home.as_path(), @@ -1336,14 +1278,7 @@ impl App { } => { let uninstall_succeeded = result.is_ok(); if uninstall_succeeded { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!( - error = %err, - "failed to refresh config after plugin uninstall" - ); - } - self.chat_widget.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.refresh_plugin_mentions_after_config_write(); } self.chat_widget.on_plugin_uninstall_loaded( cwd.clone(), @@ -1738,14 +1673,6 @@ impl App { { Ok(()) => { self.chat_widget.update_skill_enabled(path, enabled); - if !app_server.uses_remote_workspace() - && let Err(err) = self.refresh_in_memory_config_from_disk().await - { - tracing::warn!( - error = %err, - "failed to refresh config after skill toggle" - ); - } } Err(err) => { let path_display = path.display(); @@ -1782,11 +1709,6 @@ impl App { { Ok(_) => { self.chat_widget.update_connector_enabled(&id, enabled); - if !app_server.uses_remote_workspace() - && let Err(err) = self.refresh_in_memory_config_from_disk().await - { - tracing::warn!(error = %err, "failed to refresh config after app toggle"); - } } Err(err) => { self.chat_widget.add_error_message(format!( @@ -2131,6 +2053,11 @@ impl App { } } + fn refresh_plugin_mentions_after_config_write(&mut self) { + self.chat_widget.refresh_plugin_mentions(); + self.chat_widget.submit_op(AppCommand::reload_user_config()); + } + async fn apply_keymap_clear(&mut self, context: String, action: String) { let keymap_config = match crate::keymap_setup::keymap_without_custom_binding( &self.config.tui_keymap, diff --git a/codex-rs/tui/src/app/platform_actions.rs b/codex-rs/tui/src/app/platform_actions.rs index c87776f48b..d11e410884 100644 --- a/codex-rs/tui/src/app/platform_actions.rs +++ b/codex-rs/tui/src/app/platform_actions.rs @@ -21,20 +21,25 @@ impl App { permission_profile: PermissionProfile, tx: AppEventSender, ) { - let Ok(sandbox_policy) = permission_profile.to_legacy_sandbox_policy(cwd.as_path()) else { - send_world_writable_scan_failed(&tx); + let Ok(permissions) = + codex_windows_sandbox::ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_cwd( + &permission_profile, + cwd.as_path(), + ) + else { return; }; tokio::task::spawn_blocking(move || { let logs_base_dir_path = logs_base_dir.as_path(); - let result = codex_windows_sandbox::apply_world_writable_scan_and_denies( - logs_base_dir_path, - cwd.as_path(), - &env_map, - &sandbox_policy, - Some(logs_base_dir_path), - ); + let result = + codex_windows_sandbox::apply_world_writable_scan_and_denies_for_permissions( + logs_base_dir_path, + cwd.as_path(), + &env_map, + &permissions, + Some(logs_base_dir_path), + ); if result.is_err() { // Scan failed: warn without examples. send_world_writable_scan_failed(&tx); diff --git a/codex-rs/tui/src/app/plugin_mentions.rs b/codex-rs/tui/src/app/plugin_mentions.rs index ef300896bc..5d94f51c08 100644 --- a/codex-rs/tui/src/app/plugin_mentions.rs +++ b/codex-rs/tui/src/app/plugin_mentions.rs @@ -1,125 +1,62 @@ //! Plugin mention capability enrichment for the TUI. //! -//! Mention inventory comes from app-server `plugin/list`, while mention eligibility still reuses -//! the older local bulk capability summaries. That keeps the feature app-server-shaped without -//! paying for a `plugin/read` per plugin. +//! Mention inventory comes from app-server `plugin/list`, matching the GUI +//! client. The current API exposes plugin-level mention metadata there, but not +//! effective per-session capability summaries. use super::background_requests::request_plugin_list; use super::*; +use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginListResponse; -use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginSummary; -use codex_core_plugins::PluginsManager; use codex_plugin::PluginCapabilitySummary; -use std::collections::HashMap; - -#[derive(Debug, Clone)] -struct PluginMentionEntry { - config_name: String, - display_name: String, - description: Option, -} - -impl PluginMentionEntry { - fn capability_summary( - self, - capabilities_by_config_name: &HashMap, - ) -> Option { - let capabilities = capabilities_by_config_name.get(&self.config_name)?; - Some(PluginCapabilitySummary { - config_name: self.config_name, - display_name: self.display_name, - description: self.description, - has_skills: capabilities.has_skills, - mcp_server_names: capabilities.mcp_server_names.clone(), - app_connector_ids: capabilities.app_connector_ids.clone(), - }) - } -} pub(super) async fn fetch_plugin_mentions( request_handle: AppServerRequestHandle, - config: crate::legacy_core::config::Config, + cwd: PathBuf, ) -> Result> { - let response = request_plugin_list(request_handle, config.cwd.to_path_buf()).await?; - let mention_entries = plugin_mention_entries_from_list_response(response); - let capabilities_by_config_name = load_plugin_mention_capabilities(&config).await; - - Ok(mention_entries - .into_iter() - .filter_map(|entry| entry.capability_summary(&capabilities_by_config_name)) - .collect()) + let response = request_plugin_list(request_handle, cwd).await?; + Ok(plugin_mentions_from_list_response(response)) } -async fn load_plugin_mention_capabilities( - config: &crate::legacy_core::config::Config, -) -> HashMap { - let plugins_input = config.plugins_config_input(); - PluginsManager::new(config.codex_home.to_path_buf()) - .plugins_for_config(&plugins_input) - .await - .capability_summaries() - .iter() - .cloned() - .map(|summary| (summary.config_name.clone(), summary)) - .collect() -} - -fn plugin_mention_entries_from_list_response( +fn plugin_mentions_from_list_response( response: PluginListResponse, -) -> Vec { +) -> Vec { response .marketplaces .into_iter() - .flat_map(plugin_mention_entries_from_marketplace) + .flat_map(|marketplace| { + let marketplace_name = marketplace.name; + marketplace + .plugins + .into_iter() + .filter_map(move |plugin| plugin_mention_from_summary(&marketplace_name, plugin)) + }) .collect() } -fn plugin_mention_entries_from_marketplace( - marketplace: PluginMarketplaceEntry, -) -> Vec { - let marketplace_name = marketplace.name; - marketplace - .plugins - .into_iter() - .filter_map(|plugin| plugin_mention_entry(&marketplace_name, plugin)) - .collect() +fn plugin_is_eligible_for_mentions(plugin: &PluginSummary) -> bool { + plugin.installed && plugin.enabled && plugin.availability != PluginAvailability::DisabledByAdmin } -fn plugin_mention_entry( +fn plugin_mention_from_summary( marketplace_name: &str, plugin: PluginSummary, -) -> Option { +) -> Option { if !plugin_is_eligible_for_mentions(&plugin) { return None; } - let config_name = plugin_mention_config_name(marketplace_name, &plugin)?; - Some(PluginMentionEntry { - config_name, + Some(PluginCapabilitySummary { + config_name: plugin.id.clone(), display_name: plugin_mention_display_name(&plugin), - description: plugin_mention_description(&plugin), + description: plugin_mention_description(marketplace_name, &plugin), + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), }) } -fn plugin_is_eligible_for_mentions(plugin: &PluginSummary) -> bool { - plugin.installed && plugin.enabled -} - -fn plugin_mention_config_name(marketplace_name: &str, plugin: &PluginSummary) -> Option { - codex_plugin::PluginId::new(plugin.name.clone(), marketplace_name.to_string()) - .map(|plugin_id| plugin_id.as_key()) - .map_err(|err| { - tracing::warn!( - plugin_name = plugin.name, - marketplace_name, - error = %err, - "skipping plugin mention with invalid identity" - ); - }) - .ok() -} - fn plugin_mention_display_name(plugin: &PluginSummary) -> String { plugin .interface @@ -131,17 +68,80 @@ fn plugin_mention_display_name(plugin: &PluginSummary) -> String { .unwrap_or_else(|| plugin.name.clone()) } -fn plugin_mention_description(plugin: &PluginSummary) -> Option { +fn plugin_mention_description(marketplace_name: &str, plugin: &PluginSummary) -> Option { plugin .interface .as_ref() - .and_then(|interface| { - interface - .short_description - .as_deref() - .or(interface.long_description.as_deref()) - }) + .and_then(|interface| interface.short_description.as_deref()) .map(str::trim) .filter(|description| !description.is_empty()) .map(str::to_string) + .or_else(|| { + let marketplace_name = marketplace_name.trim(); + (!marketplace_name.is_empty()).then(|| marketplace_name.to_string()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::PluginAuthPolicy; + use codex_app_server_protocol::PluginAvailability; + use codex_app_server_protocol::PluginInstallPolicy; + use codex_app_server_protocol::PluginListResponse; + use codex_app_server_protocol::PluginMarketplaceEntry; + use codex_app_server_protocol::PluginSource; + use pretty_assertions::assert_eq; + + #[test] + fn plugin_mentions_use_plugin_list_summaries_and_gui_eligibility() { + let active = plugin_summary("active"); + let mut disabled_by_admin = plugin_summary("disabled-by-admin"); + disabled_by_admin.availability = PluginAvailability::DisabledByAdmin; + let mut disabled = plugin_summary("disabled"); + disabled.enabled = false; + let mut uninstalled = plugin_summary("uninstalled"); + uninstalled.installed = false; + + let response = PluginListResponse { + marketplaces: vec![PluginMarketplaceEntry { + name: "server-marketplace".to_string(), + path: None, + interface: None, + plugins: vec![active, disabled_by_admin, disabled, uninstalled], + }], + marketplace_load_errors: Vec::new(), + featured_plugin_ids: Vec::new(), + }; + + assert_eq!( + plugin_mentions_from_list_response(response), + vec![PluginCapabilitySummary { + config_name: "active@server-marketplace".to_string(), + display_name: "active".to_string(), + description: Some("server-marketplace".to_string()), + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }] + ); + } + + fn plugin_summary(name: &str) -> PluginSummary { + PluginSummary { + id: format!("{name}@server-marketplace"), + remote_plugin_id: Some(format!("plugins~{name}")), + local_version: None, + name: name.to_string(), + share_context: None, + source: PluginSource::Remote, + installed: true, + enabled: true, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnInstall, + availability: PluginAvailability::Available, + interface: None, + keywords: Vec::new(), + } + } } diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs index 6ef1721266..34e25697e2 100644 --- a/codex-rs/tui/src/app/resize_reflow.rs +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -27,11 +27,12 @@ use super::InitialHistoryReplayBuffer; use crate::history_cell; use crate::history_cell::HistoryCell; use crate::insert_history::HistoryLineWrapPolicy; +use crate::terminal_hyperlinks::HyperlinkLine; use crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE; use crate::tui; struct ReflowCellDisplay { - lines: Vec>, + lines: Vec, is_stream_continuation: bool, } @@ -41,7 +42,7 @@ struct ReflowCellDisplay { /// already-wrapped rows. Callers should keep treating `transcript_cells` as the source of truth; the /// rows here are a transient render product for a single terminal width. pub(super) struct ReflowRenderResult { - pub(super) lines: Vec>, + pub(super) lines: Vec, } pub(super) fn trailing_run_start(transcript_cells: &[Arc]) -> usize { @@ -75,12 +76,12 @@ impl App { &mut self, cell: &dyn HistoryCell, width: u16, - ) -> Vec> { + ) -> Vec { let mut display = - cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()); + cell.display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode()); if !display.is_empty() && !cell.is_stream_continuation() { if self.has_emitted_history_lines { - display.insert(0, Line::from("")); + display.insert(/*index*/ 0, HyperlinkLine::new(Line::from(""))); } else { self.has_emitted_history_lines = true; } @@ -101,7 +102,10 @@ impl App { if self.overlay.is_some() { self.deferred_history_lines.extend(display); } else { - tui.insert_history_lines_with_wrap_policy(display, self.history_line_wrap_policy()); + tui.insert_history_hyperlink_lines_with_wrap_policy( + display, + self.history_line_wrap_policy(), + ); } } @@ -153,14 +157,20 @@ impl App { let width = tui.terminal.last_known_screen_size.width; let reflowed_lines = self.render_transcript_lines_for_reflow(width).lines; if !reflowed_lines.is_empty() { - tui.insert_history_lines(reflowed_lines); + tui.insert_history_hyperlink_lines_with_wrap_policy( + reflowed_lines, + self.history_line_wrap_policy(), + ); } } return; } let retained_lines = buffer.retained_lines.into_iter().collect::>(); - tui.insert_history_lines_with_wrap_policy(retained_lines, self.history_line_wrap_policy()); + tui.insert_history_hyperlink_lines_with_wrap_policy( + retained_lines, + self.history_line_wrap_policy(), + ); } pub(super) fn insert_history_cell_lines_with_initial_replay_buffer( @@ -190,7 +200,10 @@ impl App { } else if self.overlay.is_some() { self.deferred_history_lines.extend(display); } else { - tui.insert_history_lines_with_wrap_policy(display, self.history_line_wrap_policy()); + tui.insert_history_hyperlink_lines_with_wrap_policy( + display, + self.history_line_wrap_policy(), + ); } } } @@ -210,7 +223,7 @@ impl App { /// here would make copy, transcript overlay, and future replay paths disagree about history. pub(super) fn buffer_initial_history_replay_display_lines( buffer: &mut InitialHistoryReplayBuffer, - display: Vec>, + display: Vec, max_rows: usize, ) { buffer.retained_lines.extend(display); @@ -437,7 +450,7 @@ impl App { self.deferred_history_lines.clear(); if !reflowed_lines.is_empty() { - tui.insert_history_lines_with_wrap_policy( + tui.insert_history_hyperlink_lines_with_wrap_policy( reflowed_lines, self.history_line_wrap_policy(), ); @@ -462,7 +475,8 @@ impl App { while start > 0 { start -= 1; let cell = self.transcript_cells[start].clone(); - let lines = cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()); + let lines = cell + .display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode()); rendered_rows += lines.len(); cell_displays.push_front(ReflowCellDisplay { lines, @@ -482,7 +496,10 @@ impl App { start -= 1; let cell = self.transcript_cells[start].clone(); cell_displays.push_front(ReflowCellDisplay { - lines: cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()), + lines: cell.display_hyperlink_lines_for_mode( + width, + self.chat_widget.history_render_mode(), + ), is_stream_continuation: cell.is_stream_continuation(), }); } @@ -492,7 +509,7 @@ impl App { for display in cell_displays { if !display.lines.is_empty() && !display.is_stream_continuation { if has_emitted_history_lines { - reflowed_lines.push(Line::from("")); + reflowed_lines.push(HyperlinkLine::new(Line::from(""))); } else { has_emitted_history_lines = true; } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index c2138ba31a..3fa71d7b45 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -147,6 +147,7 @@ async fn handle_mcp_inventory_result_respects_origin_thread() { app.handle_mcp_inventory_result( Ok(vec![McpServerStatus { name: "docs".to_string(), + server_info: None, tools: HashMap::new(), resources: Vec::new(), resource_templates: Vec::new(), @@ -338,6 +339,7 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm TurnStatus::Completed, vec![ThreadItem::UserMessage { id: "user-1".to_string(), + client_id: None, content: vec![AppServerUserInput::Text { text: "earlier prompt".to_string(), text_elements: Vec::new(), @@ -3263,6 +3265,7 @@ async fn side_thread_snapshot_hides_forked_parent_transcript() { TurnStatus::Completed, vec![ThreadItem::UserMessage { id: "parent-user".to_string(), + client_id: None, content: vec![AppServerUserInput::Text { text: "parent prompt should stay hidden".to_string(), text_elements: Vec::new(), @@ -3906,8 +3909,9 @@ fn plain_line_cell(text: impl Into) -> Arc { Arc::new(PlainHistoryCell::new(vec![Line::from(text.into())])) as Arc } -fn rendered_line_text(line: &Line<'static>) -> String { - line.spans +fn rendered_line_text(line: &crate::terminal_hyperlinks::HyperlinkLine) -> String { + line.line + .spans .iter() .map(|span| span.content.as_ref()) .collect() @@ -4019,7 +4023,7 @@ async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() { app.initial_history_replay_buffer .as_mut() .expect("initial replay buffer active"), - vec![Line::from(format!("line {index}"))], + vec![Line::from(format!("line {index}")).into()], /*max_rows*/ 3, ); } @@ -4688,6 +4692,7 @@ async fn replay_thread_snapshot_replays_turn_history_in_order() { items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::UserMessage { id: "user-1".to_string(), + client_id: None, content: vec![AppServerUserInput::Text { text: "first prompt".to_string(), text_elements: Vec::new(), @@ -4705,6 +4710,7 @@ async fn replay_thread_snapshot_replays_turn_history_in_order() { items: vec![ ThreadItem::UserMessage { id: "user-2".to_string(), + client_id: None, content: vec![AppServerUserInput::Text { text: "third prompt".to_string(), text_elements: Vec::new(), @@ -4852,6 +4858,7 @@ async fn refreshed_snapshot_session_persists_resumed_turns() { TurnStatus::Completed, vec![ThreadItem::UserMessage { id: "user-1".to_string(), + client_id: None, content: vec![AppServerUserInput::Text { text: "restored prompt".to_string(), text_elements: Vec::new(), @@ -4925,7 +4932,7 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() { app.transcript_cells.clone(), app.keymap.pager.clone(), )); - app.deferred_history_lines = vec![Line::from("stale buffered line")]; + app.deferred_history_lines = vec![Line::from("stale buffered line").into()]; app.backtrack.overlay_preview_active = true; app.backtrack.nth_user_message = 1; @@ -5459,7 +5466,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { app.transcript_cells.clone(), crate::keymap::RuntimeKeymap::defaults().pager, )); - app.deferred_history_lines = vec![Line::from("stale buffered line")]; + app.deferred_history_lines = vec![Line::from("stale buffered line").into()]; app.has_emitted_history_lines = true; app.backtrack.primed = true; app.backtrack.overlay_preview_active = true; diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index b0c9e7e1be..a4e514a433 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -104,10 +104,10 @@ impl ThreadEventStore { ServerNotification::TurnStarted(turn) => { self.active_turn_id = Some(turn.turn.id.clone()); } - ServerNotification::TurnCompleted(turn) => { - if self.active_turn_id.as_deref() == Some(turn.turn.id.as_str()) { - self.active_turn_id = None; - } + ServerNotification::TurnCompleted(turn) + if self.active_turn_id.as_deref() == Some(turn.turn.id.as_str()) => + { + self.active_turn_id = None; } ServerNotification::ThreadClosed(_) => { self.active_turn_id = None; diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 8f15aed79a..a403dc232c 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -684,7 +684,6 @@ impl App { } AppCommand::ReloadUserConfig => { app_server.reload_user_config().await?; - self.refresh_in_memory_config_from_disk().await?; Ok(true) } AppCommand::OverrideTurnContext { .. } => { diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 625f8d0841..075a4a5ae2 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -245,7 +245,10 @@ impl App { let was_backtrack = self.backtrack.overlay_preview_active; if !self.deferred_history_lines.is_empty() { let lines = std::mem::take(&mut self.deferred_history_lines); - tui.insert_history_lines_with_wrap_policy(lines, self.history_line_wrap_policy()); + tui.insert_history_hyperlink_lines_with_wrap_policy( + lines, + self.history_line_wrap_policy(), + ); } self.overlay = None; self.backtrack.overlay_preview_active = false; @@ -263,8 +266,11 @@ impl App { .chat_widget .history_wrap_width(tui.terminal.last_known_screen_size.width); for cell in &self.transcript_cells { - tui.insert_history_lines_with_wrap_policy( - cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()), + tui.insert_history_hyperlink_lines_with_wrap_policy( + cell.display_hyperlink_lines_for_mode( + width, + self.chat_widget.history_render_mode(), + ), self.history_line_wrap_policy(), ); } @@ -399,7 +405,7 @@ impl App { tui.draw(u16::MAX, |frame| { let width = frame.area().width.max(1); t.sync_live_tail(width, active_key, |w| { - chat_widget.active_cell_transcript_lines(w) + chat_widget.active_cell_transcript_hyperlink_lines(w) }); t.render(frame.area(), frame.buffer); })?; diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index aa2d5a645b..2b7b1cd885 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -668,6 +668,7 @@ impl AppServerSession { request_id, params: TurnStartParams { thread_id: thread_id.to_string(), + client_user_message_id: None, input: items, responsesapi_client_metadata: None, additional_context: None, @@ -732,6 +733,7 @@ impl AppServerSession { request_id, params: TurnSteerParams { thread_id: thread_id.to_string(), + client_user_message_id: None, input: items, responsesapi_client_metadata: None, additional_context: None, @@ -2264,6 +2266,7 @@ mod tests { items: vec![ codex_app_server_protocol::ThreadItem::UserMessage { id: "user-1".to_string(), + client_id: None, content: vec![codex_app_server_protocol::UserInput::Text { text: "hello from history".to_string(), text_elements: Vec::new(), @@ -2300,6 +2303,7 @@ mod tests { .into(), active_permission_profile: None, reasoning_effort: None, + initial_turns_page: None, }; let started = started_thread_from_resume_response( diff --git a/codex-rs/tui/src/bottom_pane/AGENTS.md b/codex-rs/tui/src/bottom_pane/AGENTS.md index b5328217db..b07fa2eca8 100644 --- a/codex-rs/tui/src/bottom_pane/AGENTS.md +++ b/codex-rs/tui/src/bottom_pane/AGENTS.md @@ -4,8 +4,6 @@ When changing the paste-burst or chat-composer state machines in this folder, ke - Update the relevant module docs (`chat_composer.rs` and/or `paste_burst.rs`) so they remain a readable, top-down explanation of the current behavior. -- Update the narrative doc `docs/tui-chat-composer.md` whenever behavior/assumptions change (Enter - handling, retro-capture, flush/clear rules, `disable_paste_burst`, non-ASCII/IME handling). - Keep implementations/docstrings aligned unless a divergence is intentional and documented. Practical check: diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index 7fe180a16f..bbd7f355c3 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -814,6 +814,7 @@ impl crate::render::renderable::Renderable for AppLinkView { Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(inner, buf); + crate::terminal_hyperlinks::mark_url_hyperlink(buf, inner, &self.url); if actions_area.height > 0 { let actions_area = Rect { @@ -1005,7 +1006,10 @@ mod tests { if symbol.is_empty() { ' ' } else { - symbol.chars().next().unwrap_or(' ') + crate::terminal_hyperlinks::strip_osc8(symbol) + .chars() + .next() + .unwrap_or(' ') } }) .collect::() @@ -1325,7 +1329,10 @@ mod tests { if symbol.is_empty() { ' ' } else { - symbol.chars().next().unwrap_or(' ') + crate::terminal_hyperlinks::strip_osc8(symbol) + .chars() + .next() + .unwrap_or(' ') } }) .collect::() diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8fcda378fc..21208de946 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -111,7 +111,6 @@ //! composer flushes/clears any in-flight burst state so it cannot leak into subsequent input. //! //! For the detailed burst state machine, see `codex-rs/tui/src/bottom_pane/paste_burst.rs`. -//! For a narrative overview of the combined state machine, see `docs/tui-chat-composer.md`. //! //! # PasteBurst Integration Points //! @@ -257,7 +256,6 @@ use std::path::PathBuf; use std::time::Duration; use std::time::Instant; -#[cfg(test)] use ratatui::style::Color; /// If the pasted content exceeds this number of characters, replace it with a @@ -591,6 +589,7 @@ impl ChatComposer { pub fn set_mentions_v2_enabled(&mut self, enabled: bool) { self.mentions_v2_enabled = enabled; + self.history.set_at_mention_restore_enabled(enabled); self.sync_popups(); } @@ -610,11 +609,13 @@ impl ChatComposer { pub(crate) fn take_mention_bindings(&mut self) -> Vec { let elements = self.current_mention_elements(); let mut ordered = Vec::new(); - for (id, mention) in elements { + for (id, sigil, mention) in elements { if let Some(binding) = self.draft.mention_bindings.remove(&id) + && binding.sigil == sigil && binding.mention == mention { ordered.push(MentionBinding { + sigil: binding.sigil, mention: binding.mention, path: binding.path, }); @@ -1196,7 +1197,7 @@ impl ChatComposer { /// /// This is the "fresh draft" path: it clears pending paste payloads and /// mention link targets. Callers restoring a previously submitted draft - /// that must keep `$name -> path` resolution should use + /// that must keep sigiled mention target resolution should use /// [`Self::set_text_content_with_mention_bindings`] instead. pub(crate) fn set_text_content( &mut self, @@ -1650,7 +1651,7 @@ impl ChatComposer { result } - /// Return true if either the slash-command popup or the file-search popup is active. + /// Return true if any popup or history search is active. pub(crate) fn popup_active(&self) -> bool { self.history_search.is_some() || self.popups.active() } @@ -1803,7 +1804,6 @@ impl ChatComposer { KeyEvent { code: KeyCode::Esc, .. } => { - // Hide popup without modifying text, remember token to avoid immediate reopen. if let Some(tok) = Self::current_at_token(&self.draft.textarea) { self.popups.dismissed_file_token = Some(tok); } @@ -1828,24 +1828,17 @@ impl ChatComposer { }; let sel_path = sel.to_string_lossy().to_string(); - // If selected path looks like an image (png/jpeg), attach as image instead of inserting text. - let is_image = Self::is_image_path(&sel_path); - if is_image { - // Determine dimensions; if that fails fall back to normal path insertion. + if Self::is_image_path(&sel_path) { let path_buf = PathBuf::from(&sel_path); match image::image_dimensions(&path_buf) { Ok((width, height)) => { tracing::debug!("selected image dimensions={}x{}", width, height); - // Remove the current @token (mirror logic from insert_selected_path without inserting text) - // using the flat text and byte-offset cursor API. let cursor_offset = self.draft.textarea.cursor(); let text = self.draft.textarea.text(); - // Clamp to a valid char boundary to avoid panics when slicing. let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); let before_cursor = &text[..safe_cursor]; let after_cursor = &text[safe_cursor..]; - // Determine token boundaries in the full text. let start_idx = before_cursor .char_indices() .rfind(|(_, c)| c.is_whitespace()) @@ -1862,17 +1855,14 @@ impl ChatComposer { self.draft.textarea.set_cursor(start_idx); self.attach_image(path_buf); - // Add a trailing space to keep typing fluid. self.draft.textarea.insert_str(" "); } Err(err) => { tracing::trace!("image dimensions lookup failed: {err}"); - // Fallback to plain path insertion if metadata read fails. self.insert_selected_path(&sel_path); } } } else { - // Non-image: inserting file path. self.insert_selected_path(&sel_path); } self.popups.active = ActivePopup::None; @@ -1882,6 +1872,7 @@ impl ChatComposer { } } + /// Handle key events when the legacy skill mention popup is visible. fn handle_key_event_with_skill_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { if self.handle_shortcut_overlay_key(&key_event) { return (InputResult::None, true); @@ -1963,6 +1954,7 @@ impl ChatComposer { return (InputResult::None, true); } self.footer.mode = reset_mode_after_activity(self.footer.mode); + let can_switch_search_mode = self.current_editable_at_token().is_some(); let ActivePopup::MentionV2(popup) = &mut self.popups.active else { unreachable!(); @@ -1970,6 +1962,7 @@ impl ChatComposer { let mut selected: Option = None; let mut close_popup = false; + let mut submit_without_popup = false; let result = match key_event { KeyEvent { @@ -2000,16 +1993,24 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, .. } => { - popup.previous_search_mode(); - (InputResult::None, true) + if can_switch_search_mode { + popup.previous_search_mode(); + (InputResult::None, true) + } else { + self.handle_input_basic(key_event) + } } KeyEvent { code: KeyCode::Right, modifiers: KeyModifiers::NONE, .. } => { - popup.next_search_mode(); - (InputResult::None, true) + if can_switch_search_mode { + popup.next_search_mode(); + (InputResult::None, true) + } else { + self.handle_input_basic(key_event) + } } KeyEvent { code: KeyCode::Esc, .. @@ -2022,14 +2023,19 @@ impl ChatComposer { } KeyEvent { code: KeyCode::Tab, .. + } => { + selected = popup.selected(); + close_popup = true; + (InputResult::None, true) } - | KeyEvent { + KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. } => { selected = popup.selected(); close_popup = true; + submit_without_popup = selected.is_none(); (InputResult::None, true) } input => self.handle_input_basic(input), @@ -2047,6 +2053,9 @@ impl ChatComposer { } } self.popups.active = ActivePopup::None; + if submit_without_popup { + return self.handle_key_event_without_popup(key_event); + } } result @@ -2242,14 +2251,14 @@ impl ChatComposer { /// second `@` in `@scope/pkg@latest`), keep treating the surrounding /// whitespace-delimited token as the active token rather than starting a /// new token at that nested prefix. - /// - If the token under the cursor starts with `prefix`, that token is - /// returned without the leading prefix. When `allow_empty` is true, a - /// lone prefix character yields `Some(String::new())` to surface hints. - fn current_prefixed_token( + /// - If the token under the cursor starts with `prefix`, its byte range and + /// text without the leading prefix are returned. When `allow_empty` is + /// true, a lone prefix character yields `Some(String::new())` to surface hints. + fn current_prefixed_token_range( textarea: &TextArea, prefix: char, allow_empty: bool, - ) -> Option { + ) -> Option<(Range, String)> { let cursor_offset = textarea.cursor(); let text = textarea.text(); @@ -2322,15 +2331,17 @@ impl ChatComposer { let left_match = token_left.filter(|t| t.starts_with(prefix)); let right_match = token_right.filter(|t| t.starts_with(prefix)); - let left_prefixed = left_match.map(|t| t[prefix.len_utf8()..].to_string()); - let right_prefixed = right_match.map(|t| t[prefix.len_utf8()..].to_string()); + let left_prefixed = + left_match.map(|t| (start_left..end_left, t[prefix.len_utf8()..].to_string())); + let right_prefixed = + right_match.map(|t| (start_right..end_right, t[prefix.len_utf8()..].to_string())); if at_whitespace { if right_prefixed.is_some() { return right_prefixed; } if token_left.is_some_and(|t| t == prefix_str) { - return allow_empty.then(String::new); + return allow_empty.then(|| (start_left..end_left, String::new())); } return left_prefixed; } @@ -2348,6 +2359,14 @@ impl ChatComposer { left_prefixed.or(right_prefixed) } + fn current_prefixed_token( + textarea: &TextArea, + prefix: char, + allow_empty: bool, + ) -> Option { + Self::current_prefixed_token_range(textarea, prefix, allow_empty).map(|(_, token)| token) + } + /// Extract the `@token` that the cursor is currently positioned on, if any. /// /// The returned string **does not** include the leading `@`. @@ -2355,11 +2374,48 @@ impl ChatComposer { Self::current_prefixed_token(textarea, '@', /*allow_empty*/ false) } + fn current_editable_at_token_with_options(&self, allow_empty: bool) -> Option { + let (range, token) = + Self::current_prefixed_token_range(&self.draft.textarea, '@', allow_empty)?; + if self + .draft + .textarea + .element_id_for_exact_range(range.clone()) + .is_some() + { + return None; + } + + let name_len = token + .as_bytes() + .iter() + .take_while(|byte| is_mention_name_char(**byte)) + .count(); + let mention_end = range.start + '@'.len_utf8() + name_len; + if name_len > 0 + && mention_end < range.end + && ends_plaintext_at_mention(self.draft.textarea.text().as_bytes(), mention_end) + && self + .draft + .textarea + .element_id_for_exact_range(range.start..mention_end) + .is_some() + { + return None; + } + + Some(token) + } + + fn current_editable_at_token(&self) -> Option { + self.current_editable_at_token_with_options(/*allow_empty*/ false) + } + fn current_mentions_v2_token(&self) -> Option { if !self.mentions_v2_enabled { return None; } - Self::current_prefixed_token(&self.draft.textarea, '@', /*allow_empty*/ true) + self.current_editable_at_token_with_options(/*allow_empty*/ true) } fn current_mention_token(&self) -> Option { @@ -2442,12 +2498,13 @@ impl ChatComposer { self.draft.textarea.set_cursor(start_idx); let id = self.draft.textarea.insert_element(insert_text); - if let (Some(path), Some(mention)) = - (path, Self::mention_name_from_insert_text(insert_text)) + if let (Some(path), Some((sigil, mention))) = + (path, Self::mention_token_from_insert_text(insert_text)) { self.draft.mention_bindings.insert( id, ComposerMentionBinding { + sigil, mention, path: path.to_string(), }, @@ -2461,8 +2518,12 @@ impl ChatComposer { self.draft.textarea.set_cursor(new_cursor); } - fn mention_name_from_insert_text(insert_text: &str) -> Option { - let name = insert_text.strip_prefix('$')?; + fn mention_token_from_insert_text(insert_text: &str) -> Option<(char, String)> { + let sigil = insert_text.chars().next()?; + if !matches!(sigil, '$' | '@') { + return None; + } + let name = &insert_text[sigil.len_utf8()..]; if name.is_empty() { return None; } @@ -2471,31 +2532,33 @@ impl ChatComposer { .iter() .all(|byte| is_mention_name_char(*byte)) { - Some(name.to_string()) + Some((sigil, name.to_string())) } else { None } } - fn current_mention_elements(&self) -> Vec<(u64, String)> { + fn current_mention_elements(&self) -> Vec<(u64, char, String)> { self.draft .textarea .text_element_snapshots() .into_iter() .filter_map(|snapshot| { - Self::mention_name_from_insert_text(snapshot.text.as_str()) - .map(|mention| (snapshot.id, mention)) + Self::mention_token_from_insert_text(snapshot.text.as_str()) + .map(|(sigil, mention)| (snapshot.id, sigil, mention)) }) .collect() } fn snapshot_mention_bindings(&self) -> Vec { let mut ordered = Vec::new(); - for (id, mention) in self.current_mention_elements() { + for (id, sigil, mention) in self.current_mention_elements() { if let Some(binding) = self.draft.mention_bindings.get(&id) + && binding.sigil == sigil && binding.mention == mention { ordered.push(MentionBinding { + sigil: binding.sigil, mention: binding.mention.clone(), path: binding.path.clone(), }); @@ -2513,7 +2576,7 @@ impl ChatComposer { let text = self.draft.textarea.text().to_string(); let mut scan_from = 0usize; for binding in mention_bindings { - let token = format!("${}", binding.mention); + let token = format!("{}{}", binding.sigil, binding.mention); let Some(range) = find_next_mention_token_range(text.as_str(), token.as_str(), scan_from) else { @@ -2532,6 +2595,7 @@ impl ChatComposer { self.draft.mention_bindings.insert( id, ComposerMentionBinding { + sigil: binding.sigil, mention: binding.mention, path: binding.path, }, @@ -2541,6 +2605,21 @@ impl ChatComposer { } } + fn plugin_at_mention_highlights(&self) -> Vec<(Range, Style)> { + self.draft + .textarea + .text_element_snapshots() + .into_iter() + .filter_map(|snapshot| { + let binding = self.draft.mention_bindings.get(&snapshot.id)?; + if !binding.path.starts_with("plugin://") || !snapshot.text.starts_with('@') { + return None; + } + Some((snapshot.range, Style::default().fg(Color::Magenta))) + }) + .collect() + } + /// Prepare text for submission/queuing. Returns None if submission should be suppressed. /// On success, clears pending paste payloads because placeholders have been expanded. /// @@ -3412,7 +3491,7 @@ impl ChatComposer { let file_token = if self.mentions_v2_enabled { None } else { - Self::current_at_token(&self.draft.textarea) + self.current_editable_at_token() }; let browsing_history = self .history @@ -3504,6 +3583,9 @@ impl ChatComposer { && self .slash_input() .is_editing_command_name(first_line, cursor); + let command_filter_text = caret_on_first_line + .then(|| slash_input::command_popup_filter_text(first_line, cursor)) + .flatten(); // If the cursor is currently positioned within an `@token`, prefer the // file-search popup over the slash popup so users can insert a file path @@ -3517,24 +3599,26 @@ impl ChatComposer { match &mut self.popups.active { ActivePopup::Command(popup) => { if is_editing_slash_command_name { - popup.on_composer_text_change(first_line.to_string()); + if let Some(command_filter_text) = command_filter_text.as_deref() { + popup.on_composer_text_change(command_filter_text.to_string()); + } } else { self.popups.active = ActivePopup::None; } } _ => { - if is_editing_slash_command_name { - let command_popup = self.slash_input().command_popup(first_line); + if is_editing_slash_command_name + && let Some(command_filter_text) = command_filter_text.as_deref() + { + let command_popup = self.slash_input().command_popup(command_filter_text); self.popups.active = ActivePopup::Command(command_popup); } } } } - /// Synchronize `self.file_search_popup` with the current text in the textarea. - /// Note this is only called when the active popup is NOT Command. + /// Synchronize the legacy file-search popup with the current `@` token. fn sync_file_search_popup(&mut self, query: String) { - // If user dismissed popup for this exact query, don't reopen until text changes. if self.popups.dismissed_file_token.as_ref() == Some(&query) { return; } @@ -3895,16 +3979,46 @@ fn is_mention_name_char(byte: u8) -> bool { matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-') } +fn ends_plaintext_at_mention(bytes: &[u8], index: usize) -> bool { + bytes.get(index).is_none_or(|byte| { + byte.is_ascii_whitespace() + || *byte == b'.' + && bytes.get(index + 1).is_none_or(|next| { + next.is_ascii_whitespace() + || !next.is_ascii_alphanumeric() && *next != b'_' && *next != b'-' + }) + || !matches!(*byte, b'.' | b'/' | b'\\') + && !byte.is_ascii_alphanumeric() + && *byte != b'_' + && *byte != b'-' + }) +} + +fn starts_plaintext_at_mention(text: &str, index: usize) -> bool { + if index == 0 { + return true; + } + + text.get(..index) + .and_then(|prefix| prefix.chars().next_back()) + .is_some_and(|ch| ch.is_whitespace() || !is_mention_name_char_char(ch)) +} + +fn is_mention_name_char_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-') +} + fn find_next_mention_token_range(text: &str, token: &str, from: usize) -> Option> { if token.is_empty() || from >= text.len() { return None; } let bytes = text.as_bytes(); let token_bytes = token.as_bytes(); + let sigil = *token_bytes.first()?; let mut index = from; while index < bytes.len() { - if bytes[index] != b'$' { + if bytes[index] != sigil { index += 1; continue; } @@ -3918,10 +4032,24 @@ fn find_next_mention_token_range(text: &str, token: &str, from: usize) -> Option continue; } - if bytes - .get(end) - .is_none_or(|byte| !is_mention_name_char(*byte)) - { + // Fix for restored `@` mentions: rebinding must not attach to embedded substrings such + // as email addresses, while preserving the existing `$` mention matching behavior. + let starts_plaintext_mention = if sigil == b'@' { + starts_plaintext_at_mention(text, index) + } else { + true + }; + // Fix for restored `@` mentions: mirror history encoding's trailing boundary so path-like + // text such as `@sample/pkg` is not rebound as the plain `@sample` mention. + let ends_plaintext_mention = if sigil == b'@' { + ends_plaintext_at_mention(bytes, end) + } else { + bytes + .get(end) + .is_none_or(|byte| !is_mention_name_char(*byte)) + }; + + if starts_plaintext_mention && ends_plaintext_mention { return Some(index..end); } @@ -4280,8 +4408,15 @@ impl ChatComposer { .textarea .render_ref_masked(textarea_rect, buf, &mut state, mask_char); } else { - let highlight_ranges = self.history_search_highlight_ranges(); - if highlight_ranges.is_empty() { + let mut highlights = self.plugin_at_mention_highlights(); + let search_highlight_style = + Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD); + highlights.extend( + self.history_search_highlight_ranges() + .into_iter() + .map(|range| (range, search_highlight_style)), + ); + if highlights.is_empty() { StatefulWidgetRef::render_ref( &(&self.draft.textarea), textarea_rect, @@ -4289,12 +4424,6 @@ impl ChatComposer { &mut state, ); } else { - let highlight_style = - Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD); - let highlights = highlight_ranges - .into_iter() - .map(|range| (range, highlight_style)) - .collect::>(); self.draft.textarea.render_ref_styled_with_highlights( textarea_rect, buf, @@ -4730,6 +4859,142 @@ mod tests { ); } + fn plugin_mention_foreground_color(composer: &ChatComposer) -> Option { + let area = Rect::new(0, 0, 40, 5); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let textarea_row = 1; + let row_text = (0..area.width) + .map(|x| { + buf[(x, textarea_row)] + .symbol() + .chars() + .next() + .unwrap_or(' ') + }) + .collect::(); + let mention_x = row_text + .find("@sample") + .expect("expected plugin mention in composer row"); + buf[(mention_x as u16, textarea_row)].style().fg + } + + #[test] + fn plugin_at_mentions_use_plugin_accent_style() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content_with_mention_bindings( + "@sample plugin".to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + ); + + assert_eq!( + plugin_mention_foreground_color(&composer), + Some(Color::Magenta) + ); + } + + #[test] + fn plugin_at_mentions_render_with_plugin_accent_snapshot() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content_with_mention_bindings( + "@sample plugin".to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + ); + + let area = Rect::new(0, 0, 40, 5); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + + let textarea_row = 1; + let mut text = String::new(); + let mut magenta = String::new(); + for x in 0..area.width { + let cell = &buf[(x, textarea_row)]; + text.push(cell.symbol().chars().next().unwrap_or(' ')); + magenta.push(if cell.style().fg == Some(Color::Magenta) { + '^' + } else { + ' ' + }); + } + while text.ends_with(' ') { + text.pop(); + } + while magenta.ends_with(' ') { + magenta.pop(); + } + + insta::assert_snapshot!( + "plugin_at_mentions_render_with_plugin_accent", + format!("text: {text}\nmagenta: {magenta}") + ); + } + + #[test] + fn recalled_plugin_at_mentions_keep_plugin_accent_style() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content_with_mention_bindings( + "@sample plugin".to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + ); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + composer.set_text_content(String::new(), Vec::new(), Vec::new()); + let (_, needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert!(needs_redraw); + + assert_eq!( + plugin_mention_foreground_color(&composer), + Some(Color::Magenta) + ); + } + #[test] fn status_line_hyperlink_marks_pr_number_cells() { let (tx, _rx) = unbounded_channel::(); @@ -6440,6 +6705,291 @@ mod tests { } } + #[test] + fn set_text_content_rebinds_at_sigiled_mentions() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let mention_bindings = vec![MentionBinding { + sigil: '@', + mention: "figma".to_string(), + path: "/tmp/user/figma/SKILL.md".to_string(), + }]; + composer.set_text_content_with_mention_bindings( + "@figma please".to_string(), + Vec::new(), + Vec::new(), + mention_bindings.clone(), + ); + + assert_eq!(composer.mention_bindings(), mention_bindings); + } + + #[test] + fn set_text_content_rebinds_matching_sigil_only() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let mention_bindings = vec![MentionBinding { + sigil: '$', + mention: "figma".to_string(), + path: "app://figma".to_string(), + }]; + composer.set_text_content_with_mention_bindings( + "@figma then $figma".to_string(), + Vec::new(), + Vec::new(), + mention_bindings.clone(), + ); + + let bound_tokens = composer + .current_mention_elements() + .into_iter() + .map(|(_, sigil, mention)| (sigil, mention)) + .collect::>(); + assert_eq!(bound_tokens, vec![('$', "figma".to_string())]); + assert_eq!(composer.mention_bindings(), mention_bindings); + } + + #[test] + fn set_text_content_rebinds_both_sigil_forms() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let mention_bindings = vec![ + MentionBinding { + sigil: '@', + mention: "figma".to_string(), + path: "plugin://figma@test".to_string(), + }, + MentionBinding { + sigil: '$', + mention: "figma".to_string(), + path: "app://figma".to_string(), + }, + ]; + composer.set_text_content_with_mention_bindings( + "@figma then $figma".to_string(), + Vec::new(), + Vec::new(), + mention_bindings.clone(), + ); + + let bound_tokens = composer + .current_mention_elements() + .into_iter() + .map(|(_, sigil, mention)| (sigil, mention)) + .collect::>(); + assert_eq!( + bound_tokens, + vec![('@', "figma".to_string()), ('$', "figma".to_string())] + ); + assert_eq!(composer.mention_bindings(), mention_bindings); + } + + #[test] + fn set_text_content_rebinds_at_mentions_after_email_substrings() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let text = "foo@sample.com then @sample".to_string(); + let mention_start = text.rfind("@sample").expect("expected bound mention token"); + let mention_range = mention_start..mention_start + "@sample".len(); + let mention_bindings = vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }]; + composer.set_text_content_with_mention_bindings( + text, + Vec::new(), + Vec::new(), + mention_bindings.clone(), + ); + + // Fix coverage: only the plaintext `@sample` should be atomic; the email substring stays editable. + assert_eq!( + composer + .draft + .textarea + .text_element_snapshots() + .into_iter() + .map(|snapshot| snapshot.range) + .collect::>(), + vec![mention_range] + ); + assert_eq!(composer.mention_bindings(), mention_bindings); + } + + #[test] + fn set_text_content_rebinds_at_mentions_after_punctuation() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let text = "Please ask (@sample)".to_string(); + let mention_start = text.find("@sample").expect("expected bound mention token"); + let mention_range = mention_start..mention_start + "@sample".len(); + let mention_bindings = vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }]; + composer.set_text_content_with_mention_bindings( + text, + Vec::new(), + Vec::new(), + mention_bindings.clone(), + ); + + assert_eq!( + composer + .draft + .textarea + .text_element_snapshots() + .into_iter() + .map(|snapshot| snapshot.range) + .collect::>(), + vec![mention_range] + ); + assert_eq!(composer.mention_bindings(), mention_bindings); + } + + #[test] + fn bound_at_mentions_do_not_block_arrow_navigation() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.set_text_content_with_mention_bindings( + "go @figma now".to_string(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '@', + mention: "figma".to_string(), + path: "plugin://figma@debug".to_string(), + }], + ); + composer.draft.textarea.set_cursor("go".len()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.cursor(), "go".len() + 1); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.cursor(), "go @figma".len()); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.cursor(), "go ".len()); + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.cursor(), "go".len()); + assert!(matches!(composer.popups.active, ActivePopup::None)); + } + + #[test] + fn restored_bound_at_mentions_do_not_open_mention_popup() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + for (text, move_cursor_to_end) in [ + ("@sample".to_string(), false), + ("Please ask @sample.".to_string(), true), + ] { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: true, + mcp_server_names: vec!["sample".to_string()], + app_connector_ids: Vec::new(), + }])); + + composer.set_text_content_with_mention_bindings( + text.clone(), + Vec::new(), + Vec::new(), + vec![MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + ); + if move_cursor_to_end { + composer.move_cursor_to_end(); + } + + assert!(matches!(composer.popups.active, ActivePopup::None)); + + let (result, consumed) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(consumed); + match result { + InputResult::Submitted { + text: submitted, .. + } => assert_eq!(submitted, text), + _ => panic!("expected restored bound mention to submit"), + } + } + } + #[test] fn enter_submits_when_file_popup_has_no_selection() { use crossterm::event::KeyCode; @@ -6472,6 +7022,39 @@ mod tests { } } + #[test] + fn enter_submits_when_unified_mention_popup_has_no_selection() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_mentions_v2_enabled(/*enabled*/ true); + + let input = "npx -y @kaeawc/auto-mobile@latest"; + composer.draft.textarea.insert_str(input); + composer.draft.textarea.set_cursor(input.len()); + composer.sync_popups(); + + assert!(matches!(composer.popups.active, ActivePopup::MentionV2(_))); + + let (result, consumed) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(consumed); + match result { + InputResult::Submitted { text, .. } => assert_eq!(text, input), + _ => panic!("expected Submitted"), + } + } + /// Behavior: if the ASCII path has a pending first char (flicker suppression) and a non-ASCII /// char arrives next, the pending ASCII char should still be preserved and the overall input /// should submit normally (i.e. we should not misclassify this as a paste burst). @@ -8777,6 +9360,7 @@ mod tests { ); let mention_bindings = vec![MentionBinding { + sigil: '$', mention: "figma".to_string(), path: "/tmp/user/figma/SKILL.md".to_string(), }]; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs index f739f10ec9..b219097ee1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs @@ -40,6 +40,7 @@ impl DraftState { #[derive(Clone, Debug)] pub(super) struct ComposerMentionBinding { + pub(super) sigil: char, pub(super) mention: String, pub(super) path: String, } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs index a7205d237a..bb9d2c6904 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs @@ -126,7 +126,11 @@ impl<'a> SlashInput<'a> { self.enabled && !text.starts_with(' ') && text.trim().starts_with('/') } - pub(super) fn command_element_range(&self, first_line: &str) -> Option> { + pub(super) fn command_element_range( + &self, + first_line: &str, + cursor: usize, + ) -> Option> { if self.is_bash_mode { return None; } @@ -135,6 +139,11 @@ impl<'a> SlashInput<'a> { return None; } let element_end = 1 + name.len(); + // A draft tail can make an in-progress prefix look complete ("/re" + "view"). + // Keep it editable until the cursor leaves the command name. + if cursor <= first_line.len() && (1..element_end).contains(&cursor) { + return None; + } let has_space_after = first_line .get(element_end..) .and_then(|tail| tail.chars().next()) @@ -159,7 +168,7 @@ impl<'a> SlashInput<'a> { has_slash_command_prefix(name, self.command_flags, self.service_tier_commands) } - pub(super) fn command_popup(&self, first_line: &str) -> CommandPopup { + pub(super) fn command_popup(&self, filter_text: &str) -> CommandPopup { let mut command_popup = CommandPopup::new( CommandPopupFlags { collaboration_modes_enabled: self.command_flags.collaboration_modes_enabled, @@ -175,7 +184,7 @@ impl<'a> SlashInput<'a> { }, self.service_tier_commands.to_vec(), ); - command_popup.on_composer_text_change(first_line.to_string()); + command_popup.on_composer_text_change(filter_text.to_string()); command_popup } @@ -255,8 +264,12 @@ impl ChatComposer { } => { // Ensure popup filtering/selection reflects the latest composer text // before applying completion. - let first_line = self.draft.textarea.text().lines().next().unwrap_or(""); - popup.on_composer_text_change(first_line.to_string()); + let text = self.draft.textarea.text(); + let first_line = text.lines().next().unwrap_or("").to_owned(); + let cursor = self.draft.textarea.cursor(); + let filter_text = command_popup_filter_text(&first_line, cursor) + .unwrap_or_else(|| first_line.clone()); + popup.on_composer_text_change(filter_text); if let Some(selected_cmd) = popup.selected_item() { if selected_command_dispatches_immediately_on_tab(&selected_cmd) && let CommandItem::Builtin(cmd) = &selected_cmd @@ -267,8 +280,16 @@ impl ChatComposer { return (InputResult::Command(*cmd), true); } + if self + .complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( + &selected_cmd, + ) + { + return (InputResult::None, true); + } + if let Some(completed_text) = - selected_command_completion(first_line, &selected_cmd) + selected_command_completion(&first_line, &selected_cmd) { self.draft .textarea @@ -293,11 +314,23 @@ impl ChatComposer { } => { // Treat "/" as accepting the highlighted command as text completion // while the slash-command popup is active. - let first_line = self.draft.textarea.text().lines().next().unwrap_or(""); - popup.on_composer_text_change(first_line.to_string()); + let text = self.draft.textarea.text(); + let first_line = text.lines().next().unwrap_or("").to_owned(); + let cursor = self.draft.textarea.cursor(); + let filter_text = command_popup_filter_text(&first_line, cursor) + .unwrap_or_else(|| first_line.clone()); + popup.on_composer_text_change(filter_text); if let Some(selected_cmd) = popup.selected_item() { + if self + .complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( + &selected_cmd, + ) + { + return (InputResult::None, true); + } + if let Some(completed_text) = - selected_command_completion(first_line, &selected_cmd) + selected_command_completion(&first_line, &selected_cmd) { self.draft .textarea @@ -318,6 +351,15 @@ impl ChatComposer { .. } => { if let Some(sel) = popup.selected_item() { + if self + .complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( + &sel, + ) + && let Some(result) = self.try_dispatch_slash_command_with_args() + { + return (result, true); + } + self.stage_selected_slash_command_history(&sel); self.draft.textarea.set_text_clearing_elements(""); self.draft.is_bash_mode = false; @@ -338,6 +380,71 @@ impl ChatComposer { } } + fn complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( + &mut self, + selected_cmd: &CommandItem, + ) -> bool { + let CommandItem::Builtin(cmd) = selected_cmd else { + return false; + }; + if !cmd.supports_inline_args() { + return false; + } + + let text = self.draft.textarea.text(); + let first_line_end = text.find('\n').unwrap_or(text.len()); + let cursor = self.draft.textarea.cursor(); + if cursor > first_line_end || !text.starts_with('/') || !text.is_char_boundary(cursor) { + return false; + } + + let command_token_end = text[1..first_line_end] + .find(char::is_whitespace) + .map(|idx| 1 + idx) + .unwrap_or(first_line_end); + let typed_command_name = &text[1..command_token_end]; + let rest_after_token_is_empty = text[command_token_end..].trim().is_empty(); + if rest_after_token_is_empty && (cursor <= 1 || cursor >= command_token_end) { + return false; + } + let replace_end = + if cursor <= 1 || (typed_command_name == cmd.command() && rest_after_token_is_empty) { + command_token_end + } else { + cursor + }; + let tail = &text[replace_end..]; + let tail_starts_with_whitespace = tail.chars().next().is_some_and(char::is_whitespace); + let selected_command_text = format!("/{}", cmd.command()); + let replacement = if tail_starts_with_whitespace { + selected_command_text + } else { + format!("{selected_command_text} ") + }; + + let ranges_to_unmark = self + .draft + .textarea + .text_elements() + .into_iter() + .filter_map(|element| { + let range = element.byte_range.start..element.byte_range.end; + (range.start < replace_end && replace_end < range.end).then_some(range) + }) + .collect::>(); + for range in ranges_to_unmark { + self.draft.textarea.remove_element_range(range); + } + self.draft + .textarea + .replace_range(0..replace_end, &replacement); + self.draft.is_bash_mode = false; + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); + true + } + /// Keep slash command elements aligned with the current first line. pub(super) fn sync_slash_command_elements(&mut self) { if !self.slash_commands_enabled() { @@ -346,7 +453,8 @@ impl ChatComposer { let text = self.draft.textarea.text(); let first_line_end = text.find('\n').unwrap_or(text.len()); let first_line = &text[..first_line_end]; - let desired_range = self.slash_input().command_element_range(first_line); + let cursor = self.draft.textarea.cursor(); + let desired_range = self.slash_input().command_element_range(first_line, cursor); // Slash commands are only valid at byte 0 of the first line. // Any slash-shaped element not matching the current desired prefix is stale. let mut has_desired = false; @@ -424,12 +532,20 @@ pub(super) fn args_elements( .collect() } +pub(super) fn command_popup_filter_text(first_line: &str, cursor: usize) -> Option { + let (name, _rest) = command_under_cursor(first_line, cursor)?; + Some(format!("/{name}")) +} + /// If the cursor is currently within a slash command on the first line, -/// extract the command name and the rest of the line after it. +/// extract the command fragment before the cursor and the rest of the line after it. fn command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> { if !first_line.starts_with('/') { return None; } + if cursor > first_line.len() || !first_line.is_char_boundary(cursor) { + return None; + } let name_start = 1usize; let name_end = first_line[name_start..] @@ -437,16 +553,99 @@ fn command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> .map(|idx| name_start + idx) .unwrap_or_else(|| first_line.len()); + let cursor = if cursor <= name_start { + name_end + } else { + cursor + }; if cursor > name_end { return None; } - let name = &first_line[name_start..name_end]; - let rest_start = first_line[name_end..] - .find(|c: char| !c.is_whitespace()) - .map(|idx| name_end + idx) - .unwrap_or(name_end); - let rest = &first_line[rest_start..]; + let name = &first_line[name_start..cursor]; + let rest = &first_line[cursor..]; Some((name, rest)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::bottom_pane::AppEventSender; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc::unbounded_channel; + + fn test_composer() -> ChatComposer { + let (tx, _rx) = unbounded_channel::(); + ChatComposer::new( + /*has_input_focus*/ true, + AppEventSender::new(tx), + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ) + } + + fn press(composer: &mut ChatComposer, code: KeyCode) -> InputResult { + composer + .handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)) + .0 + } + + fn composer_with_text_at_cursor(text: &str, cursor: usize) -> ChatComposer { + let mut composer = test_composer(); + composer.draft.textarea.set_text_clearing_elements(text); + composer.draft.textarea.set_cursor(cursor); + composer.sync_popups(); + composer + } + + fn composer_with_draft_tail(prefix: &str, draft: &str) -> ChatComposer { + composer_with_text_at_cursor(&format!("{prefix}{draft}"), prefix.len()) + } + + #[test] + fn slash_completion_preserves_existing_draft_tail_for_inline_arg_commands() { + let draft = "view the diff"; + let expected_text = "/review view the diff"; + + let mut composer = composer_with_draft_tail("/re", draft); + assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None); + assert_eq!(composer.draft.textarea.text(), expected_text); + assert_eq!(composer.draft.textarea.cursor(), expected_text.len()); + + let mut composer = composer_with_draft_tail("/re", draft); + assert_eq!( + press(&mut composer, KeyCode::Enter), + InputResult::CommandWithArgs(SlashCommand::Review, draft.to_string(), Vec::new()) + ); + assert_eq!(composer.draft.textarea.text(), expected_text); + } + + #[test] + fn slash_completion_does_not_preserve_existing_draft_tail_for_other_commands() { + let mut composer = composer_with_draft_tail( + "/mo", + "preserve this draft only for opted-in slash commands", + ); + + assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None); + assert_eq!(composer.draft.textarea.text(), "/model "); + assert_eq!(composer.draft.textarea.cursor(), "/model ".len()); + } + + #[test] + fn slash_completion_does_not_turn_command_suffix_into_args() { + let mut composer = composer_with_text_at_cursor("/review", "/re".len()); + assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None); + assert_eq!(composer.draft.textarea.text(), "/review "); + + let mut composer = composer_with_text_at_cursor("/review", "/re".len()); + assert_eq!( + press(&mut composer, KeyCode::Enter), + InputResult::Command(SlashCommand::Review) + ); + assert!(composer.draft.textarea.is_empty()); + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 6a490e81ec..23b553fa08 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -18,7 +18,7 @@ use std::path::PathBuf; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::MentionBinding; -use crate::mention_codec::decode_history_mentions; +use crate::mention_codec::decode_history_mentions_with_at_mentions; use codex_protocol::ThreadId; use codex_protocol::user_input::TextElement; @@ -47,7 +47,11 @@ impl HistoryEntry { /// recorded with the full `HistoryEntry` value built by the composer; using `new` for a local /// image or paste submission would make recall lose placeholder ownership. pub(crate) fn new(text: String) -> Self { - let decoded = decode_history_mentions(&text); + Self::new_with_at_mentions(text, /*at_mentions_enabled*/ true) + } + + pub(crate) fn new_with_at_mentions(text: String, at_mentions_enabled: bool) -> Self { + let decoded = decode_history_mentions_with_at_mentions(&text, at_mentions_enabled); Self { text: decoded.text, text_elements: Vec::new(), @@ -57,6 +61,7 @@ impl HistoryEntry { .mentions .into_iter() .map(|mention| MentionBinding { + sigil: mention.sigil, mention: mention.mention, path: mention.path, }) @@ -132,6 +137,8 @@ pub(crate) struct ChatComposerHistory { /// Active incremental history search, if Ctrl+R search mode is open. search: Option, + /// Whether persistent history restore should rehydrate `@` tool mentions. + at_mention_restore_enabled: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -226,9 +233,21 @@ impl ChatComposerHistory { history_cursor: None, last_history_text: None, search: None, + at_mention_restore_enabled: false, } } + pub fn set_at_mention_restore_enabled(&mut self, enabled: bool) { + if self.at_mention_restore_enabled == enabled { + return; + } + self.at_mention_restore_enabled = enabled; + self.fetched_history.clear(); + self.history_cursor = None; + self.last_history_text = None; + self.search = None; + } + /// Updates persistent history metadata when a new session is configured. /// /// This clears fetched entries, local entries, navigation cursors, and active search state @@ -393,7 +412,9 @@ impl ChatComposerHistory { return HistoryEntryResponse::Ignored; } - let entry = entry.map(HistoryEntry::new); + let entry = entry.map(|entry| { + HistoryEntry::new_with_at_mentions(entry, self.at_mention_restore_enabled) + }); if let Some(entry) = entry.clone() { self.fetched_history.insert(offset, entry); } @@ -840,6 +861,75 @@ mod tests { ); } + #[test] + fn persistent_restore_gates_at_mentions() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut history = ChatComposerHistory::new(); + history.set_metadata(test_thread_id(), /*log_id*/ 42, /*entry_count*/ 1); + + assert!(history.navigate_up(&tx).is_none()); + let disabled = history.on_entry_response( + /*log_id*/ 42, + /*offset*/ 0, + Some("[@sample](plugin://sample@test) and [$figma](app://figma)".to_string()), + &tx, + ); + assert_eq!( + disabled, + HistoryEntryResponse::Found(HistoryEntry { + text: "$sample and $figma".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + mention_bindings: vec![ + MentionBinding { + sigil: '$', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }, + MentionBinding { + sigil: '$', + mention: "figma".to_string(), + path: "app://figma".to_string(), + }, + ], + pending_pastes: Vec::new(), + }) + ); + + history.set_at_mention_restore_enabled(/*enabled*/ true); + assert!(history.navigate_up(&tx).is_none()); + let enabled = history.on_entry_response( + /*log_id*/ 42, + /*offset*/ 0, + Some("[@sample](plugin://sample@test) and [$figma](app://figma)".to_string()), + &tx, + ); + assert_eq!( + enabled, + HistoryEntryResponse::Found(HistoryEntry { + text: "@sample and $figma".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + mention_bindings: vec![ + MentionBinding { + sigil: '@', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }, + MentionBinding { + sigil: '$', + mention: "figma".to_string(), + path: "app://figma".to_string(), + }, + ], + pending_pastes: Vec::new(), + }) + ); + } + #[test] fn navigation_with_async_fetch() { let (tx, mut rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 04bcbd0b00..ce380a867d 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -1,6 +1,7 @@ use codex_feedback::DOCTOR_REPORT_ATTACHMENT_FILENAME; use codex_feedback::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME; use codex_feedback::FeedbackDiagnostics; +use codex_feedback::WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -312,7 +313,7 @@ pub(crate) fn feedback_success_cell( include_logs: bool, thread_id: &str, feedback_audience: FeedbackAudience, -) -> history_cell::PlainHistoryCell { +) -> history_cell::WebHyperlinkHistoryCell { let prefix = if include_logs { "• Feedback uploaded." } else { @@ -358,7 +359,7 @@ pub(crate) fn feedback_success_cell( ]); } } - history_cell::PlainHistoryCell::new(lines) + history_cell::WebHyperlinkHistoryCell::new(lines) } fn issue_url_for_category( @@ -472,6 +473,7 @@ pub(crate) fn feedback_upload_consent_params( category: FeedbackCategory, rollout_path: Option, auto_review_rollout_filename: Option, + include_windows_sandbox_log: bool, feedback_diagnostics: &FeedbackDiagnostics, ) -> super::SelectionViewParams { use super::popup_consts::standard_popup_hint_line; @@ -509,6 +511,15 @@ pub(crate) fn feedback_upload_consent_params( ]) .into(), ]; + if include_windows_sandbox_log { + header_lines.push( + Line::from(vec![ + " • ".into(), + WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME.into(), + ]) + .into(), + ); + } if let Some(path) = rollout_path.as_deref() && let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_string()) { @@ -696,6 +707,7 @@ mod tests { FeedbackCategory::Bug, Some(std::path::PathBuf::from("rollout.jsonl")), Some("auto-review-rollout.jsonl".to_string()), + /*include_windows_sandbox_log*/ false, &FeedbackDiagnostics::default(), ); @@ -704,6 +716,27 @@ mod tests { insta::assert_snapshot!("feedback_upload_consent_lists_doctor_report", rendered); } + #[test] + fn feedback_upload_consent_lists_windows_sandbox_log_when_included() { + let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let params = feedback_upload_consent_params( + tx, + FeedbackCategory::Bug, + Some(std::path::PathBuf::from("rollout.jsonl")), + Some("auto-review-rollout.jsonl".to_string()), + /*include_windows_sandbox_log*/ true, + &FeedbackDiagnostics::default(), + ); + + let rendered = render_renderable(params.header.as_ref(), /*width*/ 60); + + insta::assert_snapshot!( + "feedback_upload_consent_lists_windows_sandbox_log_when_included", + rendered + ); + } + #[test] fn submit_feedback_emits_submit_event_with_trimmed_note() { let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs index 06e9b31eb8..793596b468 100644 --- a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs +++ b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs @@ -421,6 +421,7 @@ impl Renderable for MemoriesSettingsView { } if self.reset_confirmation.is_none() { self.docs_link.clone().render(docs_area, buf); + crate::terminal_hyperlinks::mark_url_hyperlink(buf, docs_area, MEMORIES_DOC_URL); } let hint_area = Rect { diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/search_catalog.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/search_catalog.rs index b1dd7f914b..a555070a86 100644 --- a/codex-rs/tui/src/bottom_pane/mentions_v2/search_catalog.rs +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/search_catalog.rs @@ -50,6 +50,7 @@ fn plugin_candidate(plugin: &PluginCapabilitySummary) -> Candidate { .config_name .split_once('@') .unwrap_or((plugin.config_name.as_str(), "")); + let mention_name = plugin_mention_name(plugin_name, plugin.display_name.as_str()); let mut search_terms = vec![plugin_name.to_string(), plugin.config_name.clone()]; if plugin.display_name != plugin_name { search_terms.push(plugin.display_name.clone()); @@ -64,12 +65,88 @@ fn plugin_candidate(plugin: &PluginCapabilitySummary) -> Candidate { search_terms, mention_type: MentionType::Plugin, selection: Selection::Tool { - insert_text: format!("${plugin_name}"), + insert_text: format!("@{mention_name}"), path: Some(format!("plugin://{}", plugin.config_name)), }, } } +fn plugin_mention_name(plugin_name: &str, display_name: &str) -> String { + let plugin_segments = split_plugin_name_segments(plugin_name); + let display_segments = split_display_name_segments(display_name); + + if plugin_segments.len() == display_segments.len() + && plugin_segments.iter().zip(&display_segments).all( + |((plugin_segment, _), display_segment)| { + plugin_segment.eq_ignore_ascii_case(display_segment.as_str()) + }, + ) + { + let mut result = String::new(); + for ((_, separator), display_segment) in plugin_segments.into_iter().zip(display_segments) { + result.push_str(display_segment.as_str()); + if let Some(separator) = separator { + result.push(separator); + } + } + return result; + } + + title_case_plugin_name(plugin_name) +} + +fn split_plugin_name_segments(plugin_name: &str) -> Vec<(String, Option)> { + let mut segments = Vec::new(); + let mut current = String::new(); + + for ch in plugin_name.chars() { + if matches!(ch, '-' | '_') { + if !current.is_empty() { + segments.push((std::mem::take(&mut current), Some(ch))); + } + } else { + current.push(ch); + } + } + + if !current.is_empty() { + segments.push((current, None)); + } + + segments +} + +fn split_display_name_segments(display_name: &str) -> Vec { + display_name + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .filter(|segment| !segment.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn title_case_plugin_name(plugin_name: &str) -> String { + let mut result = String::with_capacity(plugin_name.len()); + let mut capitalize_next = true; + + for ch in plugin_name.chars() { + if matches!(ch, '-' | '_') { + capitalize_next = true; + result.push(ch); + continue; + } + + if capitalize_next && ch.is_ascii_alphabetic() { + result.push(ch.to_ascii_uppercase()); + capitalize_next = false; + } else { + result.push(ch); + capitalize_next = false; + } + } + + result +} + fn plugin_description(plugin: &PluginCapabilitySummary) -> Option { let capability_labels = plugin_capability_labels(plugin); plugin.description.clone().or_else(|| { @@ -109,3 +186,30 @@ fn optional_skill_description(skill: &SkillMetadata) -> Option { let description = skill_description(skill).trim(); (!description.is_empty()).then(|| description.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn plugin_mention_name_uses_display_segments_when_they_match_plugin_name() { + assert_eq!( + plugin_mention_name("mcp-search", "MCP Search"), + "MCP-Search" + ); + assert_eq!( + plugin_mention_name("google_calendar", "Google Calendar"), + "Google_Calendar" + ); + } + + #[test] + fn plugin_mention_name_falls_back_to_title_cased_plugin_name() { + assert_eq!(plugin_mention_name("sample", "Sample Plugin"), "Sample"); + assert_eq!( + plugin_mention_name("browser-use", "Browser Use"), + "Browser-Use" + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 65770b61ae..7ef385570e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -25,7 +25,9 @@ use crate::bottom_pane::pending_thread_approvals::PendingThreadApprovals; use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter; use crate::key_hint; use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; use crate::keymap::RuntimeKeymap; +use crate::keymap::primary_binding; use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; @@ -81,7 +83,9 @@ pub(crate) struct LocalImageAttachment { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct MentionBinding { - /// Mention token text without the leading `$`. + /// Visible mention sigil (`$` or `@`). + pub(crate) sigil: char, + /// Mention token text without the leading sigil (`$` or `@`). pub(crate) mention: String, /// Canonical mention target (for example `app://...` or absolute SKILL.md path). pub(crate) path: String, @@ -352,6 +356,12 @@ impl BottomPane { pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { self.keymap = keymap.clone(); self.composer.set_keymap_bindings(keymap); + let interrupt_binding = primary_binding(&keymap.chat.interrupt_turn); + self.pending_input_preview + .set_interrupt_binding(interrupt_binding); + if let Some(status) = self.status.as_mut() { + status.set_interrupt_binding(interrupt_binding); + } self.request_redraw(); } @@ -617,13 +627,12 @@ impl BottomPane { .and_then(parse_slash_name) .is_some_and(|(name, _, _)| name == "agent"); - // If a task is running and a status line is visible, allow Esc to - // send an interrupt even while the composer has focus. + // If a task is running and a status line is visible, allow the + // configured action to interrupt even while the composer has focus. // When a popup is active, prefer dismissing it over interrupting the task. - if key_event.code == KeyCode::Esc - && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) + if self.keymap.chat.interrupt_turn.is_pressed(key_event) && self.is_task_running - && !is_agent_command + && !(is_agent_command && key_event.code == KeyCode::Esc) && !self.composer.popup_active() && !self.composer_should_handle_vim_insert_escape(key_event) && let Some(status) = &self.status @@ -989,6 +998,7 @@ impl BottomPane { } if let Some(status) = self.status.as_mut() { status.set_interrupt_hint_visible(/*visible*/ true); + status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn)); } self.sync_status_inline_message(); self.request_redraw(); @@ -1017,6 +1027,9 @@ impl BottomPane { self.frame_requester.clone(), self.animations_enabled, )); + if let Some(status) = self.status.as_mut() { + status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn)); + } self.sync_status_inline_message(); self.request_redraw(); } @@ -2731,6 +2744,30 @@ mod tests { ); } + #[test] + fn remapped_interrupt_turn_uses_configured_key_including_agent_drafts() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = test_pane(tx); + let mut keymap = RuntimeKeymap::defaults(); + keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))]; + pane.set_keymap_bindings(&keymap); + pane.set_task_running(/*running*/ true); + pane.insert_str("/agent "); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!( + rx.try_recv().is_err(), + "expected Esc to remain local after remapping interruption" + ); + + pane.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE)); + assert!( + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), + "expected configured key to interrupt while `/agent` is being edited" + ); + } + #[test] fn selection_view_esc_respects_remapped_list_cancel() { let (tx_raw, mut rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/paste_burst.rs b/codex-rs/tui/src/bottom_pane/paste_burst.rs index 44e3898db6..4ea8310c35 100644 --- a/codex-rs/tui/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui/src/bottom_pane/paste_burst.rs @@ -16,9 +16,6 @@ //! - buffer a burst as a single pasted string, or //! - let input flow through as normal typing. //! -//! For the higher-level view of how `PasteBurst` integrates with `ChatComposer`, see -//! `docs/tui-chat-composer.md`. -//! //! # Call Pattern //! //! `PasteBurst` is a pure state machine: it never mutates the textarea directly. The caller feeds diff --git a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs index 34e7d7d390..96cc1bdfd8 100644 --- a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -15,7 +15,7 @@ use crate::wrapping::adaptive_wrap_lines; /// The widget renders pending steers first, then rejected steers that will be /// resubmitted at end of turn, then ordinary queued user messages. Pending /// steers explain that they will be submitted after the next tool/result -/// boundary unless the user presses Esc to interrupt and send them +/// boundary unless the user invokes the interrupt binding to send them /// immediately. The edit hint at the bottom only appears when there are actual /// queued user inputs to pop back into the composer. Because some terminals /// intercept certain modifier-key combinations, the displayed binding is @@ -27,6 +27,8 @@ pub(crate) struct PendingInputPreview { /// Key combination rendered in the hint line. Defaults to Alt+Up but may /// be overridden for terminals where that chord is unavailable. edit_binding: Option, + /// Key combination rendered for immediately interrupting and sending steers. + interrupt_binding: Option, } const PREVIEW_LINE_LIMIT: usize = 3; @@ -38,6 +40,7 @@ impl PendingInputPreview { rejected_steers: Vec::new(), queued_messages: Vec::new(), edit_binding: Some(key_hint::alt(KeyCode::Up)), + interrupt_binding: Some(key_hint::plain(KeyCode::Esc)), } } @@ -48,6 +51,10 @@ impl PendingInputPreview { self.edit_binding = binding; } + pub(crate) fn set_interrupt_binding(&mut self, binding: Option) { + self.interrupt_binding = binding; + } + fn push_truncated_preview_lines( lines: &mut Vec>, wrapped: Vec>, @@ -81,16 +88,15 @@ impl PendingInputPreview { let mut lines = vec![]; if !self.pending_steers.is_empty() { - Self::push_section_header( - &mut lines, - width, - Line::from(vec![ - "Messages to be submitted after next tool call".into(), + let mut header = vec!["Messages to be submitted after next tool call".into()]; + if let Some(interrupt_binding) = self.interrupt_binding { + header.extend(vec![ " (press ".dim(), - key_hint::plain(KeyCode::Esc).into(), + interrupt_binding.into(), " to interrupt and send immediately)".dim(), - ]), - ); + ]); + } + Self::push_section_header(&mut lines, width, Line::from(header)); for steer in &self.pending_steers { let wrapped = adaptive_wrap_lines( @@ -327,6 +333,21 @@ mod tests { assert_snapshot!("render_one_pending_steer", format!("{buf:?}")); } + #[test] + fn render_one_pending_steer_with_remapped_interrupt_binding() { + let mut queue = PendingInputPreview::new(); + queue.pending_steers.push("Please continue.".to_string()); + queue.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)))); + let width = 48; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!( + "render_one_pending_steer_with_remapped_interrupt_binding", + format!("{buf:?}") + ); + } + #[test] fn render_pending_steers_above_queued_messages() { let mut queue = PendingInputPreview::new(); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 13cbc34daa..d328d9f1fd 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -143,6 +143,7 @@ pub(crate) struct RequestUserInputOverlay { pending_submission_draft: Option, confirm_unanswered: Option, composer_submit_keys: Vec, + interrupt_turn_keys: Vec, list_keymap: ListKeymap, } @@ -198,6 +199,7 @@ impl RequestUserInputOverlay { pending_submission_draft: None, confirm_unanswered: None, composer_submit_keys: keymap.composer.submit.clone(), + interrupt_turn_keys: keymap.chat.interrupt_turn.clone(), list_keymap: keymap.list, }; overlay.reset_for_request(); @@ -506,8 +508,15 @@ impl RequestUserInputOverlay { tips.push(FooterTip::new("ctrl + p / ctrl + n change question")); } } - if !(self.has_options() && notes_visible) { - tips.push(FooterTip::new("esc to interrupt")); + if let Some(interrupt_key) = self.interrupt_turn_keys.first() + && !(self.has_options() + && notes_visible + && *interrupt_key == crate::key_hint::plain(KeyCode::Esc)) + { + tips.push(FooterTip::new(format!( + "{} to interrupt", + interrupt_key.display_label() + ))); } tips } @@ -1056,11 +1065,12 @@ impl BottomPaneView for RequestUserInputOverlay { return; } - if matches!(key_event.code, KeyCode::Esc) { - if self.has_options() && self.notes_ui_visible() { - self.clear_notes_and_focus_options(); - return; - } + if matches!(key_event.code, KeyCode::Esc) && self.has_options() && self.notes_ui_visible() { + self.clear_notes_and_focus_options(); + return; + } + + if self.interrupt_turn_keys.is_pressed(key_event) { // TODO: Emit interrupted request_user_input results (including committed answers) // once core supports persisting them reliably without follow-up turn issues. self.app_event_tx.interrupt(); @@ -1188,11 +1198,9 @@ impl BottomPaneView for RequestUserInputOverlay { KeyCode::Backspace | KeyCode::Delete => { self.clear_selection(); } - KeyCode::Tab => { - if self.selected_option_index().is_some() { - self.focus = Focus::Notes; - self.ensure_selected_for_notes(); - } + KeyCode::Tab if self.selected_option_index().is_some() => { + self.focus = Focus::Notes; + self.ensure_selected_for_notes(); } KeyCode::Enter => { let has_selection = self.selected_option_index().is_some(); @@ -2045,6 +2053,40 @@ mod tests { ); } + #[test] + fn request_user_input_uses_remapped_interrupt_binding_while_notes_are_visible() { + let (tx, mut rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))]; + let mut overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(0); + overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); + + let tips = overlay.footer_tips(); + let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::>(); + assert_eq!( + tip_texts, + vec![ + "tab or esc to clear notes", + "enter to submit answer", + "f12 to interrupt", + ] + ); + + overlay.handle_key_event(KeyEvent::from(KeyCode::F(12))); + + assert_eq!(overlay.done, true); + expect_interrupt_only(&mut rx); + } + #[test] fn tab_opens_notes_when_option_selected() { let (tx, _rx) = test_sender(); @@ -3173,6 +3215,26 @@ mod tests { ); } + #[test] + fn request_user_input_freeform_remapped_interrupt_snapshot() { + let (tx, _rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))]; + let overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_without_options("q1", "Goal")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + let area = Rect::new(0, 0, 120, 10); + insta::assert_snapshot!( + "request_user_input_freeform_remapped_interrupt", + render_snapshot(&overlay, area) + ); + } + #[test] fn request_user_input_multi_question_first_snapshot() { let (tx, _rx) = test_sender(); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_interrupt.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_interrupt.snap new file mode 100644 index 0000000000..0b26b354e3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_interrupt.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/1 (1 unanswered) + Share details. + + › Type your answer (optional) + + + + enter to submit answer | f12 to interrupt diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_at_mentions_render_with_plugin_accent.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_at_mentions_render_with_plugin_accent.snap new file mode 100644 index 0000000000..0f6c47dca0 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_at_mentions_render_with_plugin_accent.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: "format!(\"text: {text}\\nmagenta: {magenta}\")" +--- +text: › @sample plugin +magenta: ^^^^^^^ diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap index 6cca54542d..e91d736ecd 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap @@ -1,6 +1,6 @@ --- source: tui/src/bottom_pane/feedback_view.rs -assertion_line: 704 +assertion_line: 716 expression: rendered --- Upload logs? diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_windows_sandbox_log_when_included.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_windows_sandbox_log_when_included.snap new file mode 100644 index 0000000000..741505d5f5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_windows_sandbox_log_when_included.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +assertion_line: 734 +expression: rendered +--- +Upload logs? + +The following files will be sent: + • codex-logs.log + • codex-doctor-report.json + • windows-sandbox.log + • rollout.jsonl + • auto-review-rollout.jsonl diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer_with_remapped_interrupt_binding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer_with_remapped_interrupt_binding.snap new file mode 100644 index 0000000000..f8a3caa1bf --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer_with_remapped_interrupt_binding.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 48, height: 3 }, + content: [ + "• Messages to be submitted after next tool call ", + " (press f12 to interrupt and send immediately) ", + " ↳ Please continue. ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 47, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 20, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__vim_e_advances_from_each_word_end.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__vim_e_advances_from_each_word_end.snap new file mode 100644 index 0000000000..0a41c2d80f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__vim_e_advances_from_each_word_end.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "states.join(\"\\n\\n\")" +--- +alpha beta gamma + ^ + +alpha beta gamma + ^ + +alpha beta gamma + ^ diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 96f6cf8cbb..39da5752c2 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -16,6 +16,7 @@ use crate::keymap::EditorKeymap; use crate::keymap::RuntimeKeymap; use crate::keymap::VimNormalKeymap; use crate::keymap::VimOperatorKeymap; +use crate::keymap::VimTextObjectKeymap; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement as UserTextElement; use crossterm::event::KeyCode; @@ -35,6 +36,13 @@ use textwrap::Options; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; +mod vim; +use self::vim::VimMode; +use self::vim::VimMotion; +use self::vim::VimOperator; +use self::vim::VimPending; +use self::vim::VimTextObjectScope; + const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?"; fn is_word_separator(ch: char) -> bool { @@ -101,10 +109,11 @@ pub(crate) struct TextArea { kill_buffer_kind: KillBufferKind, vim_enabled: bool, vim_mode: VimMode, - vim_operator: Option, + vim_pending: VimPending, editor_keymap: EditorKeymap, vim_normal_keymap: VimNormalKeymap, vim_operator_keymap: VimOperatorKeymap, + vim_text_object_keymap: VimTextObjectKeymap, } #[derive(Debug, Clone)] @@ -119,14 +128,6 @@ pub(crate) struct TextAreaState { scroll: u16, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum VimMode { - /// Normal mode routes printable keys to movement, operators, and mode transitions. - Normal, - /// Insert mode routes input through the regular editor keymap until Escape is pressed. - Insert, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum KillBufferKind { /// Characterwise kills and yanks paste at the cursor. @@ -135,36 +136,6 @@ enum KillBufferKind { Linewise, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum VimOperator { - /// Delete the range selected by the next motion or repeated operator key. - Delete, - /// Copy the range selected by the next motion or repeated operator key. - Yank, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum VimMotion { - /// Move one atomic boundary to the left. - Left, - /// Move one atomic boundary to the right. - Right, - /// Move one visual row up, preserving preferred display column. - Up, - /// Move one visual row down, preserving preferred display column. - Down, - /// Move to the start of the next word-like run. - WordForward, - /// Move to the start of the previous word-like run. - WordBackward, - /// Move to the end of the current or next word-like run. - WordEnd, - /// Move to the start of the current line. - LineStart, - /// Move to the end of the current line. - LineEnd, -} - impl TextArea { pub fn new() -> Self { let defaults = RuntimeKeymap::defaults(); @@ -179,10 +150,11 @@ impl TextArea { kill_buffer_kind: KillBufferKind::Characterwise, vim_enabled: false, vim_mode: VimMode::Insert, - vim_operator: None, + vim_pending: VimPending::None, editor_keymap: defaults.editor, vim_normal_keymap: defaults.vim_normal, vim_operator_keymap: defaults.vim_operator, + vim_text_object_keymap: defaults.vim_text_object, } } @@ -196,6 +168,7 @@ impl TextArea { self.editor_keymap = keymap.editor.clone(); self.vim_normal_keymap = keymap.vim_normal.clone(); self.vim_operator_keymap = keymap.vim_operator.clone(); + self.vim_text_object_keymap = keymap.vim_text_object.clone(); } /// Replace the visible textarea text and clear any existing text elements. @@ -257,7 +230,7 @@ impl TextArea { /// an old `d` or `y` command. pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { self.vim_enabled = enabled; - self.vim_operator = None; + self.vim_pending = VimPending::None; self.vim_mode = if enabled { VimMode::Normal } else { @@ -293,7 +266,7 @@ impl TextArea { /// This is observable so the composer can avoid stealing the second key of /// `d{motion}` or `y{motion}` for higher-level shortcuts. pub(crate) fn is_vim_operator_pending(&self) -> bool { - self.vim_operator.is_some() + !matches!(self.vim_pending, VimPending::None) } /// Enter Vim insert mode if modal editing is enabled. @@ -304,7 +277,7 @@ impl TextArea { pub(crate) fn enter_vim_insert_mode(&mut self) { if self.vim_enabled { self.vim_mode = VimMode::Insert; - self.vim_operator = None; + self.vim_pending = VimPending::None; } } @@ -317,7 +290,7 @@ impl TextArea { pub(crate) fn enter_vim_normal_mode(&mut self) { if self.vim_enabled { self.vim_mode = VimMode::Normal; - self.vim_operator = None; + self.vim_pending = VimPending::None; self.preferred_col = None; } } @@ -669,9 +642,17 @@ impl TextArea { } fn handle_vim_normal(&mut self, event: KeyEvent) { - if let Some(op) = self.vim_operator.take() { - self.handle_vim_operator(op, event); - return; + let pending = std::mem::replace(&mut self.vim_pending, VimPending::None); + match pending { + VimPending::None => {} + VimPending::Operator(op) => { + self.handle_vim_operator(op, event); + return; + } + VimPending::TextObject { operator, scope } => { + self.handle_vim_text_object(operator, scope, event); + return; + } } if self.vim_normal_keymap.enter_insert.is_pressed(event) { @@ -755,7 +736,12 @@ impl TextArea { return; } if self.vim_normal_keymap.delete_to_line_end.is_pressed(event) { - self.kill_to_end_of_line(); + self.vim_kill_to_end_of_line(); + return; + } + if self.vim_normal_keymap.change_to_line_end.is_pressed(event) { + self.vim_kill_to_end_of_line(); + self.vim_mode = VimMode::Insert; return; } if self.vim_normal_keymap.yank_line.is_pressed(event) { @@ -771,15 +757,23 @@ impl TextArea { .start_delete_operator .is_pressed(event) { - self.vim_operator = Some(VimOperator::Delete); + self.vim_pending = VimPending::Operator(VimOperator::Delete); return; } if self.vim_normal_keymap.start_yank_operator.is_pressed(event) { - self.vim_operator = Some(VimOperator::Yank); + self.vim_pending = VimPending::Operator(VimOperator::Yank); + return; + } + if self + .vim_normal_keymap + .start_change_operator + .is_pressed(event) + { + self.vim_pending = VimPending::Operator(VimOperator::Change); return; } if self.vim_normal_keymap.cancel_operator.is_pressed(event) { - self.vim_operator = None; + self.vim_pending = VimPending::None; } } @@ -795,14 +789,41 @@ impl TextArea { if self.vim_operator_keymap.cancel.is_pressed(event) { return true; } + if let Some(scope) = self.vim_text_object_scope_for_event(event) { + self.vim_pending = VimPending::TextObject { + operator: op, + scope, + }; + return true; + } - if let Some(motion) = self.vim_motion_for_event(event) { + if op != VimOperator::Change + && let Some(motion) = self.vim_motion_for_event(event) + { self.apply_vim_operator(op, motion); return true; } false } + fn handle_vim_text_object( + &mut self, + op: VimOperator, + scope: VimTextObjectScope, + event: KeyEvent, + ) -> bool { + if self.vim_text_object_keymap.cancel.is_pressed(event) { + return true; + } + let Some(object) = self.vim_text_object_for_event(event) else { + return false; + }; + if let Some(range) = self.text_object_range(object, scope) { + self.apply_vim_operator_to_range(op, range); + } + true + } + fn vim_motion_for_event(&self, event: KeyEvent) -> Option { if self.vim_operator_keymap.motion_left.is_pressed(event) { return Some(VimMotion::Left); @@ -849,6 +870,18 @@ impl TextArea { match op { VimOperator::Delete => self.kill_range(range), VimOperator::Yank => self.yank_range(range), + VimOperator::Change => {} + } + } + + fn apply_vim_operator_to_range(&mut self, op: VimOperator, range: Range) { + match op { + VimOperator::Delete => self.kill_range(range), + VimOperator::Yank => self.yank_range(range), + VimOperator::Change => { + self.kill_range(range); + self.vim_mode = VimMode::Insert; + } } } @@ -914,7 +947,7 @@ impl TextArea { VimMotion::Down => self.move_cursor_down(), VimMotion::WordForward => self.set_cursor(self.beginning_of_next_word()), VimMotion::WordBackward => self.set_cursor(self.beginning_of_previous_word()), - VimMotion::WordEnd => self.set_cursor(self.end_of_next_word()), + VimMotion::WordEnd => self.set_cursor(self.vim_word_end_exclusive()), VimMotion::LineStart => self.set_cursor(self.beginning_of_current_line()), VimMotion::LineEnd => self.set_cursor(self.end_of_current_line()), } @@ -1007,6 +1040,13 @@ impl TextArea { } } + fn vim_kill_to_end_of_line(&mut self) { + let eol = self.end_of_current_line(); + if self.cursor_pos < eol { + self.kill_range(self.cursor_pos..eol); + } + } + pub fn kill_to_beginning_of_line(&mut self) { let bol = self.beginning_of_current_line(); let range = if self.cursor_pos == bol { @@ -1734,7 +1774,11 @@ impl TextArea { } pub(crate) fn end_of_next_word(&self) -> usize { - let suffix = &self.text[self.cursor_pos..]; + self.end_of_next_word_from(self.cursor_pos) + } + + fn end_of_next_word_from(&self, cursor_pos: usize) -> usize { + let suffix = &self.text[cursor_pos..]; let Some(first_non_ws) = suffix.find(|ch: char| !ch.is_whitespace()) else { return self.text.len(); }; @@ -1742,16 +1786,16 @@ impl TextArea { let run = &run[..run.find(char::is_whitespace).unwrap_or(run.len())]; let mut pieces = split_word_pieces(run).into_iter().peekable(); let Some((start, piece)) = pieces.next() else { - return self.cursor_pos + first_non_ws; + return cursor_pos + first_non_ws; }; - let word_start = self.cursor_pos + first_non_ws + start; + let word_start = cursor_pos + first_non_ws + start; let mut end = word_start + piece.len(); if piece.chars().all(is_word_separator) { while let Some((idx, piece)) = pieces.peek() { if !piece.chars().all(is_word_separator) { break; } - end = self.cursor_pos + first_non_ws + *idx + piece.len(); + end = cursor_pos + first_non_ws + *idx + piece.len(); pieces.next(); } } @@ -1759,8 +1803,22 @@ impl TextArea { self.adjust_pos_out_of_elements(end, /*prefer_start*/ false) } - fn vim_word_end_cursor(&self) -> usize { + fn vim_word_end_exclusive(&self) -> usize { let end = self.end_of_next_word(); + let target = if end > self.cursor_pos { + self.prev_atomic_boundary(end) + } else { + end + }; + if target == self.cursor_pos && end < self.text.len() { + self.end_of_next_word_from(end) + } else { + end + } + } + + fn vim_word_end_cursor(&self) -> usize { + let end = self.vim_word_end_exclusive(); if end > self.cursor_pos { self.prev_atomic_boundary(end) } else { @@ -2202,7 +2260,7 @@ mod tests { let mut t = TextArea::new(); t.set_vim_enabled(/*enabled*/ true); - t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)); t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!(t.vim_mode_label(), Some("Normal")); @@ -2275,6 +2333,60 @@ mod tests { assert_eq!(t.cursor(), 11); } + #[test] + fn vim_shift_c_changes_to_line_end_and_enters_insert_mode() { + let mut t = ta_with("hello world\nnext line"); + t.set_cursor(/*pos*/ 6); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SHIFT)); + + assert_eq!(t.text(), "hello \nnext line"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + assert_eq!(t.cursor(), 6); + assert_eq!(t.kill_buffer, "world"); + } + + #[test] + fn vim_uppercase_c_changes_to_line_end() { + let mut t = ta_with("hello world\nnext line"); + t.set_cursor(/*pos*/ 6); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello \nnext line"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + assert_eq!(t.cursor(), 6); + } + + #[test] + fn vim_d_at_line_end_does_not_remove_newline() { + let mut t = ta_with("hello\nworld"); + t.set_cursor(/*pos*/ "hello".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('D'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello\nworld"); + assert_eq!(t.vim_mode_label(), Some("Normal")); + assert_eq!(t.kill_buffer, ""); + } + + #[test] + fn vim_c_at_line_end_enters_insert_without_removing_newline() { + let mut t = ta_with("hello\nworld"); + t.set_cursor(/*pos*/ "hello".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello\nworld"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + assert_eq!(t.cursor(), "hello".len()); + assert_eq!(t.kill_buffer, ""); + } + #[test] fn vim_shift_o_opens_line_above_with_shift_only_binding() { let mut t = ta_with("hello\nworld"); @@ -2315,6 +2427,180 @@ mod tests { assert_eq!(t.kill_buffer, "hello "); } + #[test] + fn vim_change_inner_word_deletes_word_and_enters_insert() { + let mut t = ta_with("hello world"); + t.set_cursor(/*pos*/ "hello ".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello "); + assert_eq!(t.kill_buffer, "world"); + assert_eq!(t.cursor(), "hello ".len()); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + + #[test] + fn vim_word_text_objects_cover_delete_yank_and_big_word() { + let mut t = ta_with("hello world"); + t.set_cursor(/*pos*/ 1); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello world"); + assert_eq!(t.kill_buffer, "hello "); + assert_eq!(t.vim_mode_label(), Some("Normal")); + + let mut t = ta_with("foo.bar/baz qux"); + t.set_cursor(/*pos*/ "foo.".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('W'), KeyModifiers::NONE)); + + assert_eq!(t.text(), " qux"); + assert_eq!(t.kill_buffer, "foo.bar/baz"); + } + + #[test] + fn vim_word_text_objects_accept_cursor_at_word_end() { + let mut t = ta_with("hello world"); + t.set_cursor(/*pos*/ "hello".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "world"); + assert_eq!(t.kill_buffer, "hello "); + + let mut t = ta_with("foo bar"); + t.set_cursor(t.text().len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('W'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "foo "); + assert_eq!(t.kill_buffer, "bar"); + assert_eq!(t.cursor(), "foo ".len()); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + + #[test] + fn vim_delimiter_text_objects_select_innermost_pair_and_aliases() { + let mut t = ta_with("a(b(c)d)e"); + t.set_cursor(/*pos*/ "a(b(".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "a(b()d)e"); + assert_eq!(t.kill_buffer, "c"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + + let mut t = ta_with("a [b] c"); + t.set_cursor(/*pos*/ "a [".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "a c"); + assert_eq!(t.kill_buffer, "[b]"); + } + + #[test] + fn vim_empty_inner_text_objects_are_valid_targets() { + let mut t = ta_with("call()"); + t.set_cursor(/*pos*/ "call(".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('('), KeyModifiers::NONE)); + + assert_eq!(t.text(), "call()"); + assert_eq!(t.kill_buffer, ""); + assert_eq!(t.cursor(), "call(".len()); + assert_eq!(t.vim_mode_label(), Some("Insert")); + + let mut t = ta_with(r#"say "" now"#); + t.set_cursor(/*pos*/ r#"say ""#.len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE)); + + assert_eq!(t.text(), r#"say "" now"#); + assert_eq!(t.kill_buffer, ""); + assert_eq!(t.cursor(), r#"say ""#.len()); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + + #[test] + fn vim_quote_text_objects_are_line_local_and_handle_escapes() { + let mut t = ta_with(r#"say "a \"b\" c" now"#); + t.set_cursor(/*pos*/ r#"say "a \"#.len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::SHIFT)); + + assert_eq!(t.text(), r#"say "" now"#); + assert_eq!(t.kill_buffer, r#"a \"b\" c"#); + assert_eq!(t.vim_mode_label(), Some("Insert")); + + let mut t = ta_with("one \"two\nthree\" four"); + t.set_cursor(/*pos*/ "one \"two\n".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "one \"two\nthree\" four"); + assert_eq!(t.kill_buffer, ""); + } + + #[test] + fn vim_text_object_cancellation_and_unsupported_change_motions_do_not_edit() { + let mut t = ta_with("hello world"); + t.set_cursor(/*pos*/ 1); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('$'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello world"); + assert_eq!(t.kill_buffer, ""); + assert_eq!(t.vim_mode_label(), Some("Normal")); + assert!(!t.is_vim_operator_pending()); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + assert!(t.is_vim_operator_pending()); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello world"); + assert_eq!(t.kill_buffer, ""); + assert!(!t.is_vim_operator_pending()); + } + #[test] fn vim_operator_invalid_motion_is_consumed() { let mut t = ta_with("hello"); @@ -2324,7 +2610,7 @@ mod tests { t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); assert!(t.is_vim_operator_pending()); - t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)); assert_eq!(t.text(), "hello"); assert_eq!(t.vim_mode_label(), Some("Normal")); @@ -2348,6 +2634,62 @@ mod tests { assert_eq!(t.kill_buffer, "c"); } + #[test] + fn vim_e_advances_from_each_word_end() { + let mut t = ta_with("alpha beta gamma"); + t.set_cursor("alph".len()); // codespell:ignore alph + t.set_vim_enabled(/*enabled*/ true); + let mut states = Vec::new(); + + for _ in 0..3 { + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + states.push(format!("{}\n{}^", t.text(), " ".repeat(t.cursor()))); + } + + insta::assert_snapshot!("vim_e_advances_from_each_word_end", states.join("\n\n")); + } + + #[test] + fn vim_delete_to_word_end_advances_from_existing_word_end() { + let mut t = ta_with("alpha beta gamma"); + t.set_cursor("alph".len()); // codespell:ignore alph + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "alph gamma"); // codespell:ignore alph + assert_eq!(t.kill_buffer, "a beta"); + } + + #[test] + fn vim_e_from_word_end_can_land_on_trailing_space() { + let mut t = ta_with("alpha "); + t.set_cursor("alph".len()); // codespell:ignore alph + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + + assert_eq!(t.cursor(), "alpha ".len()); + } + + #[test] + fn vim_e_advances_across_atomic_element_word_ends() { + let mut t = TextArea::new(); + t.insert_str("alpha "); + t.insert_element(""); + t.insert_str(" gamma"); + let element_start = t.elements[0].range.start; + t.set_cursor("alph".len()); // codespell:ignore alph + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), element_start); + + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), "alpha gamm".len()); + } + #[test] fn vim_dollar_lands_on_line_end_character() { let mut t = ta_with("abc\n123"); diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim.rs b/codex-rs/tui/src/bottom_pane/textarea/vim.rs new file mode 100644 index 0000000000..2c1edc3fe7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/vim.rs @@ -0,0 +1,320 @@ +use super::TextArea; +use super::split_word_pieces; +use crate::key_hint::KeyBindingListExt; +use crossterm::event::KeyEvent; +use std::ops::Range; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimMode { + /// Normal mode routes printable keys to movement, operators, and mode transitions. + Normal, + /// Insert mode routes input through the regular editor keymap until Escape is pressed. + Insert, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimOperator { + Delete, + Yank, + Change, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimPending { + None, + Operator(VimOperator), + TextObject { + operator: VimOperator, + scope: VimTextObjectScope, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimMotion { + Left, + Right, + Up, + Down, + WordForward, + WordBackward, + WordEnd, + LineStart, + LineEnd, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimTextObjectScope { + Inner, + Around, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimTextObject { + Word, + BigWord, + Parentheses, + Brackets, + Braces, + DoubleQuote, + SingleQuote, + Backtick, +} + +impl TextArea { + pub(super) fn vim_text_object_scope_for_event( + &self, + event: KeyEvent, + ) -> Option { + if self + .vim_operator_keymap + .select_inner_text_object + .is_pressed(event) + { + return Some(VimTextObjectScope::Inner); + } + if self + .vim_operator_keymap + .select_around_text_object + .is_pressed(event) + { + return Some(VimTextObjectScope::Around); + } + None + } + + pub(super) fn vim_text_object_for_event(&self, event: KeyEvent) -> Option { + if self.vim_text_object_keymap.word.is_pressed(event) { + return Some(VimTextObject::Word); + } + if self.vim_text_object_keymap.big_word.is_pressed(event) { + return Some(VimTextObject::BigWord); + } + if self.vim_text_object_keymap.parentheses.is_pressed(event) { + return Some(VimTextObject::Parentheses); + } + if self.vim_text_object_keymap.brackets.is_pressed(event) { + return Some(VimTextObject::Brackets); + } + if self.vim_text_object_keymap.braces.is_pressed(event) { + return Some(VimTextObject::Braces); + } + if self.vim_text_object_keymap.double_quote.is_pressed(event) { + return Some(VimTextObject::DoubleQuote); + } + if self.vim_text_object_keymap.single_quote.is_pressed(event) { + return Some(VimTextObject::SingleQuote); + } + if self.vim_text_object_keymap.backtick.is_pressed(event) { + return Some(VimTextObject::Backtick); + } + None + } + + pub(super) fn text_object_range( + &self, + object: VimTextObject, + scope: VimTextObjectScope, + ) -> Option> { + match object { + VimTextObject::Word => self.word_text_object_range(scope, /*big_word*/ false), + VimTextObject::BigWord => self.word_text_object_range(scope, /*big_word*/ true), + VimTextObject::Parentheses => self.paired_text_object_range(scope, '(', ')'), + VimTextObject::Brackets => self.paired_text_object_range(scope, '[', ']'), + VimTextObject::Braces => self.paired_text_object_range(scope, '{', '}'), + VimTextObject::DoubleQuote => self.quoted_text_object_range(scope, '"'), + VimTextObject::SingleQuote => self.quoted_text_object_range(scope, '\''), + VimTextObject::Backtick => self.quoted_text_object_range(scope, '`'), + } + } + + fn word_text_object_range( + &self, + scope: VimTextObjectScope, + big_word: bool, + ) -> Option> { + let inner = if big_word { + self.big_word_range_at_cursor()? + } else { + self.small_word_range_at_cursor()? + }; + Some(match scope { + VimTextObjectScope::Inner => inner, + VimTextObjectScope::Around => self.expand_word_around(inner), + }) + } + + fn big_word_range_at_cursor(&self) -> Option> { + self.non_ws_runs() + .into_iter() + .find(|range| self.cursor_overlaps_range(range) || self.cursor_is_at_range_end(range)) + } + + fn small_word_range_at_cursor(&self) -> Option> { + for run in self.non_ws_runs() { + if !self.cursor_overlaps_range(&run) && !self.cursor_is_at_range_end(&run) { + continue; + } + let mut last_piece = None; + for (piece_start, piece) in split_word_pieces(&self.text[run.clone()]) { + let piece = run.start + piece_start..run.start + piece_start + piece.len(); + if self.cursor_overlaps_range(&piece) { + return Some(piece); + } + last_piece = Some(piece); + } + if self.cursor_is_at_range_end(&run) { + return last_piece.or(Some(run)); + } + return Some(run); + } + None + } + + fn non_ws_runs(&self) -> Vec> { + let mut runs = Vec::new(); + let mut start = None; + for (idx, ch) in self.text.char_indices() { + if ch.is_whitespace() { + if let Some(run_start) = start.take() { + runs.push(run_start..idx); + } + } else if start.is_none() { + start = Some(idx); + } + } + if let Some(run_start) = start { + runs.push(run_start..self.text.len()); + } + runs + } + + fn cursor_overlaps_range(&self, range: &Range) -> bool { + range.start <= self.cursor_pos && self.cursor_pos < range.end + } + + fn cursor_is_at_range_end(&self, range: &Range) -> bool { + range.start < range.end && self.cursor_pos == range.end + } + + fn expand_word_around(&self, inner: Range) -> Range { + let following = self.following_whitespace_end(inner.end); + if following > inner.end { + return inner.start..following; + } + self.preceding_whitespace_start(inner.start)..inner.end + } + + fn following_whitespace_end(&self, start: usize) -> usize { + let mut end = start; + for (offset, ch) in self.text[start..].char_indices() { + if !ch.is_whitespace() { + break; + } + end = start + offset + ch.len_utf8(); + } + end + } + + fn preceding_whitespace_start(&self, end: usize) -> usize { + let mut start = end; + for (idx, ch) in self.text[..end].char_indices().rev() { + if !ch.is_whitespace() { + break; + } + start = idx; + } + start + } + + fn paired_text_object_range( + &self, + scope: VimTextObjectScope, + open: char, + close: char, + ) -> Option> { + let mut stack: Vec = Vec::new(); + let mut best: Option> = None; + for (idx, ch) in self.text.char_indices() { + if self.is_inside_element(idx) { + continue; + } + if ch == open { + stack.push(idx); + } else if ch == close { + let Some(open_idx) = stack.pop() else { + continue; + }; + let close_end = idx + ch.len_utf8(); + if open_idx <= self.cursor_pos && self.cursor_pos <= idx { + let candidate = match scope { + VimTextObjectScope::Inner => open_idx + open.len_utf8()..idx, + VimTextObjectScope::Around => open_idx..close_end, + }; + if candidate.start <= candidate.end + && best + .as_ref() + .is_none_or(|current| candidate.len() < current.len()) + { + best = Some(candidate); + } + } + } + } + best + } + + fn quoted_text_object_range( + &self, + scope: VimTextObjectScope, + quote: char, + ) -> Option> { + let line = self.beginning_of_current_line()..self.end_of_current_line(); + let mut open = None; + let mut best: Option> = None; + for (offset, ch) in self.text[line.clone()].char_indices() { + let idx = line.start + offset; + if self.is_inside_element(idx) || ch != quote || self.is_escaped(idx) { + continue; + } + if let Some(open_idx) = open.take() { + if open_idx <= self.cursor_pos && self.cursor_pos <= idx { + let candidate = match scope { + VimTextObjectScope::Inner => open_idx + quote.len_utf8()..idx, + VimTextObjectScope::Around => idx_range(open_idx, idx, quote), + }; + if candidate.start <= candidate.end + && best + .as_ref() + .is_none_or(|current| candidate.len() < current.len()) + { + best = Some(candidate); + } + } + } else { + open = Some(idx); + } + } + best + } + + fn is_inside_element(&self, pos: usize) -> bool { + self.elements + .iter() + .any(|element| pos >= element.range.start && pos < element.range.end) + } + + fn is_escaped(&self, pos: usize) -> bool { + let mut backslashes = 0; + for ch in self.text[..pos].chars().rev() { + if ch != '\\' { + break; + } + backslashes += 1; + } + backslashes % 2 == 1 + } +} + +fn idx_range(open_idx: usize, close_idx: usize, quote: char) -> Range { + open_idx..close_idx + quote.len_utf8() +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b9c5101187..d3e5cf68ef 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -10,7 +10,8 @@ //! visible immediately. //! //! The transcript overlay is kept in sync by `App::overlay_forward_event`, which syncs a live tail -//! during draws using `active_cell_transcript_key()` and `active_cell_transcript_lines()`. The +//! during draws using `active_cell_transcript_key()` and +//! `active_cell_transcript_hyperlink_lines()`. The //! cache key is designed to change when the active cell mutates in place or when its transcript //! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on //! every draw. @@ -77,6 +78,7 @@ use crate::status::StatusHistoryHandle; use crate::status::format_directory_display; use crate::status::format_tokens_compact; use crate::status::rate_limit_snapshot_display_for_limit; +use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_title::SetTerminalTitleResult; use crate::terminal_title::clear_terminal_title; use crate::terminal_title::set_terminal_title; @@ -977,12 +979,19 @@ impl ChatWidget { pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) { let snapshot = self.feedback.snapshot(self.thread_id); + #[cfg(target_os = "windows")] + let include_windows_sandbox_log = + codex_windows_sandbox::current_log_file_path_for_codex_home(&self.config.codex_home) + .is_file(); + #[cfg(not(target_os = "windows"))] + let include_windows_sandbox_log = false; let params = crate::bottom_pane::feedback_upload_consent_params( self.app_event_tx.clone(), category, self.current_rollout_path.clone(), self.thread_id .map(|thread_id| format!("auto-review-rollout-{thread_id}.jsonl")), + include_windows_sandbox_log, snapshot.feedback_diagnostics(), ); self.bottom_pane.show_selection_view(params); @@ -1817,28 +1826,37 @@ impl ChatWidget { }) } - /// Returns the active cell's transcript lines for a given terminal width. + /// Returns the active cell's annotated transcript lines for a given terminal width. /// /// This is a convenience for the transcript overlay live-tail path, and it intentionally /// filters out empty results so the overlay can treat "nothing to render" as "no tail". Callers /// should pass the same width the overlay uses; using a different width will cause wrapping /// mismatches between the main viewport and the transcript overlay. - pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option>> { + pub(crate) fn active_cell_transcript_hyperlink_lines( + &self, + width: u16, + ) -> Option> { let mut lines = Vec::new(); if let Some(cell) = self.transcript.active_cell.as_ref() { - lines.extend(cell.transcript_lines(width)); + lines.extend(cell.transcript_hyperlink_lines(width)); } if let Some(hook_cell) = self.active_hook_cell.as_ref() { // Compute hook lines first so hidden hooks do not add a separator. - let hook_lines = hook_cell.transcript_lines(width); + let hook_lines = hook_cell.transcript_hyperlink_lines(width); if !hook_lines.is_empty() && !lines.is_empty() { - lines.push("".into()); + lines.push(HyperlinkLine::from("")); } lines.extend(hook_lines); } (!lines.is_empty()).then_some(lines) } + #[cfg(test)] + pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option>> { + self.active_cell_transcript_hyperlink_lines(width) + .map(crate::terminal_hyperlinks::visible_lines) + } + /// Return a reference to the widget's current config (includes any /// runtime overrides applied via TUI, e.g., model or approval policy). pub(crate) fn config_ref(&self) -> &Config { diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index 4700ffc0cc..578a35728f 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -363,6 +363,7 @@ impl ChatWidget { let encoded_mentions = mention_bindings .iter() .map(|binding| LinkedMention { + sigil: binding.sigil, mention: binding.mention.clone(), path: binding.path.clone(), }) diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 0819bd6c03..d9451fc9d5 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -112,8 +112,7 @@ impl ChatWidget { return; } - if matches!(key_event.code, KeyCode::Esc) - && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) + if self.chat_keymap.interrupt_turn.is_pressed(key_event) && !self.input_queue.pending_steers.is_empty() && self.bottom_pane.is_task_running() && self.bottom_pane.no_modal_or_popup_active() diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index b3b58cbc82..6d56330367 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -332,10 +332,8 @@ impl ChatWidget { reasoning_effort, agents_states, }), - ThreadItem::EnteredReviewMode { review, .. } => { - if !from_replay { - self.enter_review_mode_with_hint(review, /*from_replay*/ false); - } + ThreadItem::EnteredReviewMode { review, .. } if !from_replay => { + self.enter_review_mode_with_hint(review, /*from_replay*/ false); } _ => {} } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap index 354e473a18..b860cf8fb3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests/popups_and_settings.rs +assertion_line: 2562 expression: popup --- Upload logs? @@ -7,6 +8,7 @@ expression: popup The following files will be sent: • codex-logs.log • codex-doctor-report.json + • windows-sandbox.log • auto-review-rollout-thread-1.jsonl • codex-connectivity-diagnostics.txt diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index c8d45d2a59..f4fcc85e43 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -283,6 +283,7 @@ async fn live_app_server_user_message_item_completed_does_not_duplicate_rendered completed_at_ms: 0, item: AppServerThreadItem::UserMessage { id: "user-1".to_string(), + client_id: None, content: vec![AppServerUserInput::Text { text: "Hi, are you there?".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index bfe563b94c..27ae4daff2 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -570,6 +570,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { Vec::new(), Vec::new(), vec![MentionBinding { + sigil: '$', mention: "figma".to_string(), path: user_skill_path.to_string_lossy().into_owned(), }], @@ -605,6 +606,7 @@ async fn blocked_image_restore_preserves_mention_bindings() { path: PathBuf::from("/tmp/blocked.png"), }]; let mention_bindings = vec![MentionBinding { + sigil: '$', mention: "file".to_string(), path: "/tmp/skills/file/SKILL.md".to_string(), }]; @@ -953,6 +955,32 @@ async fn pending_steer_esc_does_not_steal_vim_insert_escape() { assert!(chat.input_queue.submit_pending_steers_after_interrupt); } +#[tokio::test] +async fn pending_steer_interrupt_uses_remapped_binding() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))]; + chat.chat_keymap = keymap.chat.clone(); + chat.bottom_pane.set_keymap_bindings(&keymap); + chat.bottom_pane.set_task_running(/*running*/ true); + chat.input_queue + .pending_steers + .push_back(pending_steer("queued steer")); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(!chat.input_queue.submit_pending_steers_after_interrupt); + assert!(op_rx.try_recv().is_err()); + + chat.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE)); + + match op_rx.try_recv() { + Ok(Op::Interrupt) => {} + other => panic!("expected Op::Interrupt, got {other:?}"), + } + assert!(chat.input_queue.submit_pending_steers_after_interrupt); +} + #[tokio::test] async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -1210,6 +1238,7 @@ async fn submit_user_message_ignores_inaccessible_app_mentions_from_bindings() { remote_image_urls: Vec::new(), text_elements: Vec::new(), mention_bindings: vec![MentionBinding { + sigil: '$', mention: "arabica-uae".to_string(), path: "app://arabica_uae".to_string(), }], diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 63487f18b9..c63b617ab3 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -722,6 +722,7 @@ pub(super) fn replay_user_message_inputs( chat.replay_thread_item( AppServerThreadItem::UserMessage { id: item_id.to_string(), + client_id: None, content, }, "turn-1".to_string(), @@ -946,6 +947,7 @@ pub(super) fn complete_user_message_for_inputs( completed_at_ms: 0, item: AppServerThreadItem::UserMessage { id: item_id.to_string(), + client_id: None, content, }, }), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 5fc2fa2ce9..74cd527038 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1244,6 +1244,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { remote_image_urls: Vec::new(), text_elements: Vec::new(), mention_bindings: vec![MentionBinding { + sigil: '$', mention: "sample".to_string(), path: "plugin://sample@test".to_string(), }], diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 3a4bdd1329..ade74b8908 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2200,7 +2200,7 @@ async fn memories_settings_popup_snapshot() { chat.open_memories_popup(); - let popup = render_bottom_popup(&chat, /*width*/ 80); + let popup = strip_osc8_for_snapshot(&render_bottom_popup(&chat, /*width*/ 80)); assert_chatwidget_snapshot!("memories_settings_popup", popup); } @@ -2621,6 +2621,7 @@ async fn feedback_upload_consent_popup_snapshot() { crate::app_event::FeedbackCategory::Bug, chat.current_rollout_path.clone(), Some("auto-review-rollout-thread-1.jsonl".to_string()), + /*include_windows_sandbox_log*/ true, &codex_feedback::FeedbackDiagnostics::new(vec![codex_feedback::FeedbackDiagnostic { headline: "Proxy environment variables are set and may affect connectivity." .to_string(), @@ -2641,6 +2642,7 @@ async fn feedback_good_result_consent_popup_includes_connectivity_diagnostics_fi crate::app_event::FeedbackCategory::GoodResult, chat.current_rollout_path.clone(), Some("auto-review-rollout-thread-1.jsonl".to_string()), + /*include_windows_sandbox_log*/ false, &codex_feedback::FeedbackDiagnostics::new(vec![codex_feedback::FeedbackDiagnostic { headline: "Proxy environment variables are set and may affect connectivity." .to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 48bc341f6b..faf8636ad8 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -182,6 +182,7 @@ async fn live_app_server_review_prompt_item_is_not_rendered() { completed_at_ms: 0, item: AppServerThreadItem::UserMessage { id: "review-prompt".to_string(), + client_id: None, content: vec![AppServerUserInput::Text { text: "Review the code changes against the base branch 'main'.".to_string(), text_elements: Vec::new(), @@ -875,6 +876,7 @@ async fn manual_interrupt_restores_pending_steer_mention_bindings_to_composer() chat.on_agent_message_delta("Final answer line\n".to_string()); let mention_bindings = vec![MentionBinding { + sigil: '$', mention: "figma".to_string(), path: "/tmp/skills/figma/SKILL.md".to_string(), }]; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 0346b97069..ff1feb084c 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -656,6 +656,7 @@ async fn goal_slash_command_uses_plain_text_for_mentions() { Vec::new(), Vec::new(), vec![MentionBinding { + sigil: '$', mention: "figma".to_string(), path: "app://figma".to_string(), }], @@ -917,6 +918,7 @@ fn merged_history_record_preserves_raw_text_and_rebased_elements() { remote_image_urls: Vec::new(), text_elements: vec![TextElement::new((4..10).into(), Some("$figma".to_string()))], mention_bindings: vec![MentionBinding { + sigil: '$', mention: "figma".to_string(), path: "app://figma".to_string(), }], @@ -1034,6 +1036,7 @@ async fn interrupted_merged_message_history_encodes_mentions_once() { Vec::new(), Vec::new(), vec![MentionBinding { + sigil: '$', mention: "figma".to_string(), path: "app://figma".to_string(), }], diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 5690db4ab0..acb1df6c5a 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -393,8 +393,8 @@ async fn completed_plan_table_tail_skips_provisional_history_insert() { assert!(saw_source_backed_plan, "expected source-backed plan insert"); assert!( - rendered_plan.contains('│') || rendered_plan.contains('┌'), - "expected completed plan table to render as a boxed table, got: {rendered_plan:?}" + rendered_plan.contains('━'), + "expected completed plan table to render with separators, got: {rendered_plan:?}" ); assert!( !saw_stream_plan, diff --git a/codex-rs/tui/src/chatwidget/user_messages.rs b/codex-rs/tui/src/chatwidget/user_messages.rs index cdcf650cb1..80a36b5815 100644 --- a/codex-rs/tui/src/chatwidget/user_messages.rs +++ b/codex-rs/tui/src/chatwidget/user_messages.rs @@ -578,6 +578,7 @@ impl ChatWidget { UserInput::Text { text, text_elements: current_text_elements, + .. } => append_text_with_rebased_elements( &mut message, &mut text_elements, diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index dd90af0557..3b8dfce200 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -15,12 +15,20 @@ impl ChatWidget { } let cwd = self.config.cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); - let policy = self.config.legacy_sandbox_policy(); - match codex_windows_sandbox::apply_world_writable_scan_and_denies( + let permission_profile = self.config.permissions.effective_permission_profile(); + let Ok(permissions) = + codex_windows_sandbox::ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_cwd( + &permission_profile, + cwd.as_path(), + ) + else { + return None; + }; + match codex_windows_sandbox::apply_world_writable_scan_and_denies_for_permissions( self.config.codex_home.as_path(), cwd.as_path(), &env_map, - &policy, + &permissions, Some(self.config.codex_home.as_path()), ) { Ok(_) => None, diff --git a/codex-rs/tui/src/get_git_diff.rs b/codex-rs/tui/src/get_git_diff.rs index a7b4b668fb..7f55450707 100644 --- a/codex-rs/tui/src/get_git_diff.rs +++ b/codex-rs/tui/src/get_git_diff.rs @@ -13,6 +13,13 @@ use crate::workspace_command::WorkspaceCommandExecutor; use crate::workspace_command::WorkspaceCommandOutput; const DIFF_COMMAND_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30); +const DISABLE_FSMONITOR_CONFIG: &str = "core.fsmonitor=false"; +const DISABLE_HOOKS_CONFIG: &str = if cfg!(windows) { + "core.hooksPath=NUL" +} else { + "core.hooksPath=/dev/null" +}; +const EXECUTABLE_FILTER_CONFIG_PATTERN: &str = r"^filter\..*\.(clean|process)$"; /// Return value of [`get_git_diff`]. /// @@ -27,9 +34,22 @@ pub(crate) async fn get_git_diff( return Ok((false, String::new())); } - // Run tracked diff and untracked file listing in parallel. + // Keep `/diff` informational: repository configuration must not select executable diff helpers. + let diff_config_overrides = diff_filter_config_overrides(runner, cwd).await?; let (tracked_diff_res, untracked_output_res) = tokio::join!( - run_git_capture_diff(runner, cwd, &["diff", "--color"]), + run_git_capture_diff( + runner, + cwd, + &diff_config_overrides, + &[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + ] + ), run_git_capture_stdout(runner, cwd, &["ls-files", "--others", "--exclude-standard"]), ); let tracked_diff = tracked_diff_res?; @@ -48,8 +68,19 @@ pub(crate) async fn get_git_diff( .map(str::trim) .filter(|s| !s.is_empty()) { - let args = ["diff", "--color", "--no-index", "--", null_path, file]; - let diff = run_git_capture_diff(runner, cwd, &args).await?; + let args = [ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + "--no-index", + "--", + null_path, + file, + ]; + let diff = run_git_capture_diff(runner, cwd, &diff_config_overrides, &args).await?; untracked_diff.push_str(&diff); } @@ -63,7 +94,7 @@ async fn run_git_capture_stdout( cwd: &Path, args: &[&str], ) -> Result { - let output = run_git_command(runner, cwd, args).await?; + let output = run_git_command(runner, cwd, &[], args).await?; if output.success() { Ok(output.stdout) } else { @@ -79,9 +110,10 @@ async fn run_git_capture_stdout( async fn run_git_capture_diff( runner: &dyn WorkspaceCommandExecutor, cwd: &Path, + config_overrides: &[(String, String)], args: &[&str], ) -> Result { - let output = run_git_command(runner, cwd, args).await?; + let output = run_git_command(runner, cwd, config_overrides, args).await?; if output.success() || output.exit_code == 1 { Ok(output.stdout) } else { @@ -92,32 +124,88 @@ async fn run_git_capture_diff( } } +/// Return Git configuration overrides that prevent configured filter drivers +/// from executing while generating diffs. +async fn diff_filter_config_overrides( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Result, String> { + let args = [ + "config", + "--null", + "--name-only", + "--get-regexp", + EXECUTABLE_FILTER_CONFIG_PATTERN, + ]; + let output = run_git_command(runner, cwd, &[], &args).await?; + if output.exit_code != 0 && output.exit_code != 1 { + return Err(format!( + "git {:?} failed with status {}", + args, output.exit_code + )); + } + + let mut drivers = output + .stdout + .split('\0') + .filter_map(|key| { + key.strip_suffix(".clean") + .or_else(|| key.strip_suffix(".process")) + }) + .map(str::to_string) + .collect::>(); + drivers.sort(); + drivers.dedup(); + + Ok(drivers + .into_iter() + .flat_map(|driver| { + [ + (format!("{driver}.clean"), String::new()), + (format!("{driver}.process"), String::new()), + (format!("{driver}.required"), "false".to_string()), + ] + }) + .collect()) +} + /// Determine if the current directory is inside a Git repository. async fn inside_git_repo( runner: &dyn WorkspaceCommandExecutor, cwd: &Path, ) -> Result { - let output = run_git_command(runner, cwd, &["rev-parse", "--is-inside-work-tree"]).await?; + let output = run_git_command(runner, cwd, &[], &["rev-parse", "--is-inside-work-tree"]).await?; Ok(output.success()) } async fn run_git_command( runner: &dyn WorkspaceCommandExecutor, cwd: &Path, + config_overrides: &[(String, String)], args: &[&str], ) -> Result { - let mut argv = Vec::with_capacity(args.len() + 1); + let mut argv = Vec::with_capacity(args.len() + 5); argv.push("git".to_string()); + argv.extend([ + "-c".to_string(), + DISABLE_FSMONITOR_CONFIG.to_string(), + "-c".to_string(), + DISABLE_HOOKS_CONFIG.to_string(), + ]); argv.extend(args.iter().map(|arg| (*arg).to_string())); - runner - .run( - WorkspaceCommand::new(argv) - .cwd(cwd.to_path_buf()) - .timeout(DIFF_COMMAND_TIMEOUT) - .disable_output_cap(), - ) - .await - .map_err(|err| err.to_string()) + let mut command = WorkspaceCommand::new(argv) + .cwd(cwd.to_path_buf()) + .timeout(DIFF_COMMAND_TIMEOUT) + .disable_output_cap(); + if !config_overrides.is_empty() { + command = command.env("GIT_CONFIG_COUNT", config_overrides.len().to_string()); + for (index, (key, value)) in config_overrides.iter().enumerate() { + command = command + .env(format!("GIT_CONFIG_KEY_{index}"), key) + .env(format!("GIT_CONFIG_VALUE_{index}"), value); + } + } + runner.run(command).await.map_err(|err| err.to_string()) } #[cfg(test)] @@ -125,17 +213,24 @@ mod tests { use super::*; use crate::workspace_command::WorkspaceCommandError; use pretty_assertions::assert_eq; + use std::collections::HashMap; use std::collections::VecDeque; + #[cfg(unix)] + use std::fs; use std::future::Future; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::pin::Pin; + #[cfg(unix)] + use std::process::Command as ProcessCommand; use std::sync::Mutex; #[tokio::test] async fn get_git_diff_returns_not_git_for_non_git_cwd() { let cwd = PathBuf::from("/workspace"); let runner = FakeRunner::new(vec![response( - &["git", "rev-parse", "--is-inside-work-tree"], + git_command(&["rev-parse", "--is-inside-work-tree"]), /*exit_code*/ 128, "", )]); @@ -145,40 +240,61 @@ mod tests { assert_eq!(result, Ok((false, String::new()))); assert_commands( &runner.commands(), - &[&["git", "rev-parse", "--is-inside-work-tree"]], + &[git_command(&["rev-parse", "--is-inside-work-tree"])], &cwd, ); } #[tokio::test] - async fn get_git_diff_concatenates_tracked_and_untracked_diffs() { + async fn get_git_diff_disables_helpers_for_tracked_and_untracked_diffs() { let cwd = PathBuf::from("/workspace"); let runner = FakeRunner::new(vec![ response( - &["git", "rev-parse", "--is-inside-work-tree"], + git_command(&["rev-parse", "--is-inside-work-tree"]), /*exit_code*/ 0, "true\n", ), response( - &["git", "diff", "--color"], + git_command(&[ + "config", + "--null", + "--name-only", + "--get-regexp", + EXECUTABLE_FILTER_CONFIG_PATTERN, + ]), + /*exit_code*/ 0, + "filter.evil.clean\0filter.evil.process\0", + ), + response( + git_command(&[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + ]), /*exit_code*/ 1, "tracked\n", ), response( - &["git", "ls-files", "--others", "--exclude-standard"], + git_command(&["ls-files", "--others", "--exclude-standard"]), /*exit_code*/ 0, "new.txt\n", ), response( - &[ - "git", + git_command(&[ "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", "--color", "--no-index", "--", null_device(), "new.txt", - ], + ]), /*exit_code*/ 1, "untracked\n", ), @@ -187,24 +303,44 @@ mod tests { let result = get_git_diff(&runner, &cwd).await; assert_eq!(result, Ok((true, "tracked\nuntracked\n".to_string()))); + let commands = runner.commands(); assert_commands( - &runner.commands(), + &commands, &[ - &["git", "rev-parse", "--is-inside-work-tree"], - &["git", "diff", "--color"], - &["git", "ls-files", "--others", "--exclude-standard"], - &[ - "git", + git_command(&["rev-parse", "--is-inside-work-tree"]), + git_command(&[ + "config", + "--null", + "--name-only", + "--get-regexp", + EXECUTABLE_FILTER_CONFIG_PATTERN, + ]), + git_command(&[ "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + ]), + git_command(&["ls-files", "--others", "--exclude-standard"]), + git_command(&[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", "--color", "--no-index", "--", null_device(), "new.txt", - ], + ]), ], &cwd, ); + assert_eq!(commands[2].env, filter_override_env("filter.evil")); + assert_eq!(commands[4].env, filter_override_env("filter.evil")); } #[tokio::test] @@ -212,17 +348,35 @@ mod tests { let cwd = PathBuf::from("/workspace"); let runner = FakeRunner::new(vec![ response( - &["git", "rev-parse", "--is-inside-work-tree"], + git_command(&["rev-parse", "--is-inside-work-tree"]), /*exit_code*/ 0, "true\n", ), response( - &["git", "diff", "--color"], + git_command(&[ + "config", + "--null", + "--name-only", + "--get-regexp", + EXECUTABLE_FILTER_CONFIG_PATTERN, + ]), + /*exit_code*/ 1, + "", + ), + response( + git_command(&[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + ]), /*exit_code*/ 1, "tracked\n", ), response( - &["git", "ls-files", "--others", "--exclude-standard"], + git_command(&["ls-files", "--others", "--exclude-standard"]), /*exit_code*/ 0, "", ), @@ -238,13 +392,35 @@ mod tests { let cwd = PathBuf::from("/workspace"); let runner = FakeRunner::new(vec![ response( - &["git", "rev-parse", "--is-inside-work-tree"], + git_command(&["rev-parse", "--is-inside-work-tree"]), /*exit_code*/ 0, "true\n", ), - response(&["git", "diff", "--color"], /*exit_code*/ 2, ""), response( - &["git", "ls-files", "--others", "--exclude-standard"], + git_command(&[ + "config", + "--null", + "--name-only", + "--get-regexp", + EXECUTABLE_FILTER_CONFIG_PATTERN, + ]), + /*exit_code*/ 1, + "", + ), + response( + git_command(&[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--submodule=short", + "--ignore-submodules=dirty", + "--color", + ]), + /*exit_code*/ 2, + "", + ), + response( + git_command(&["ls-files", "--others", "--exclude-standard"]), /*exit_code*/ 0, "", ), @@ -255,14 +431,180 @@ mod tests { .expect_err("unexpected git diff status should fail"); assert!( - error.contains("git [\"diff\", \"--color\"] failed with status 2"), + error.contains( + "git [\"diff\", \"--no-textconv\", \"--no-ext-diff\", \"--submodule=short\", \"--ignore-submodules=dirty\", \"--color\"] failed with status 2" + ), "unexpected error: {error}", ); } - fn response(argv: &[&str], exit_code: i32, stdout: &str) -> FakeResponse { + #[cfg(unix)] + #[tokio::test] + async fn get_git_diff_does_not_execute_configured_filters_fsmonitor_or_hooks() { + let tempdir = tempfile::tempdir().expect("create temp directory"); + let repo = tempdir.path().join("repo"); + fs::create_dir(&repo).expect("create test repository directory"); + run_git_setup(&repo, &["init", "-q"]); + run_git_setup(&repo, &["config", "user.name", "test"]); + run_git_setup(&repo, &["config", "user.email", "test@example.com"]); + fs::write(repo.join(".gitattributes"), "*.txt filter=x=y\n").expect("write attributes"); + fs::write(repo.join("tracked.txt"), "before\n").expect("write tracked file"); + fs::write(repo.join("unchanged.txt"), "unchanged\n").expect("write unchanged file"); + run_git_setup( + &repo, + &["add", ".gitattributes", "tracked.txt", "unchanged.txt"], + ); + run_git_setup(&repo, &["commit", "-qm", "initial"]); + + let filter_helper = tempdir.path().join("filter-helper.sh"); + let fsmonitor_helper = tempdir.path().join("fsmonitor-helper.sh"); + let hooks_dir = tempdir.path().join("hooks"); + let hook_helper = hooks_dir.join("post-index-change"); + fs::create_dir(&hooks_dir).expect("create hooks directory"); + write_marker_helper(&filter_helper); + write_marker_helper(&fsmonitor_helper); + write_marker_helper(&hook_helper); + run_git_setup( + &repo, + &[ + "config", + "filter.x=y.clean", + filter_helper.to_str().expect("filter helper path"), + ], + ); + run_git_setup( + &repo, + &[ + "config", + "filter.x=y.process", + filter_helper.to_str().expect("filter helper path"), + ], + ); + run_git_setup(&repo, &["config", "filter.x=y.required", "true"]); + run_git_setup( + &repo, + &[ + "config", + "core.fsmonitor", + fsmonitor_helper.to_str().expect("fsmonitor helper path"), + ], + ); + run_git_setup( + &repo, + &[ + "config", + "core.hooksPath", + hooks_dir.to_str().expect("hooks directory path"), + ], + ); + std::thread::sleep(Duration::from_secs(/*secs*/ 1)); + fs::write(repo.join("unchanged.txt"), "unchanged\n").expect("refresh unchanged file"); + fs::write(repo.join("tracked.txt"), "after\n").expect("modify tracked file"); + + let result = get_git_diff(&LocalRunner, &repo) + .await + .expect("generate diff without invoking helpers"); + + assert!(result.1.contains("before")); + assert!(result.1.contains("after")); + assert!(!filter_helper.with_extension("sh.ran").exists()); + assert!(!fsmonitor_helper.with_extension("sh.ran").exists()); + assert!(!hook_helper.with_extension("sh.ran").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn get_git_diff_does_not_execute_helpers_while_checking_dirty_submodules() { + let tempdir = tempfile::tempdir().expect("create temp directory"); + let child = tempdir.path().join("child"); + let repo = tempdir.path().join("repo"); + fs::create_dir(&child).expect("create child repository directory"); + fs::create_dir(&repo).expect("create parent repository directory"); + run_git_setup(&child, &["init", "-q"]); + run_git_setup(&child, &["config", "user.name", "test"]); + run_git_setup(&child, &["config", "user.email", "test@example.com"]); + fs::write(child.join(".gitattributes"), "*.txt filter=evil\n") + .expect("write child attributes"); + fs::write(child.join("tracked.txt"), "before\n").expect("write child tracked file"); + run_git_setup(&child, &["add", ".gitattributes", "tracked.txt"]); + run_git_setup(&child, &["commit", "-qm", "initial"]); + + run_git_setup(&repo, &["init", "-q"]); + run_git_setup(&repo, &["config", "user.name", "test"]); + run_git_setup(&repo, &["config", "user.email", "test@example.com"]); + run_git_setup( + &repo, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "-q", + child.to_str().expect("child repository path"), + "child", + ], + ); + run_git_setup(&repo, &["commit", "-qm", "add submodule"]); + + let helper = tempdir.path().join("submodule-helper.sh"); + write_marker_helper(&helper); + let checkout = repo.join("child"); + run_git_setup( + &checkout, + &[ + "config", + "filter.evil.clean", + helper.to_str().expect("submodule helper path"), + ], + ); + run_git_setup(&checkout, &["config", "filter.evil.required", "true"]); + std::thread::sleep(Duration::from_secs(/*secs*/ 1)); + fs::write(checkout.join("tracked.txt"), "before\n").expect("refresh child tracked file"); + + let result = get_git_diff(&LocalRunner, &repo) + .await + .expect("generate diff without inspecting submodule worktrees"); + + assert!(result.1.is_empty()); + assert!(!helper.with_extension("sh.ran").exists()); + } + + fn git_command(args: &[&str]) -> Vec { + let mut argv = vec![ + "git".to_string(), + "-c".to_string(), + DISABLE_FSMONITOR_CONFIG.to_string(), + "-c".to_string(), + DISABLE_HOOKS_CONFIG.to_string(), + ]; + argv.extend(args.iter().map(|arg| (*arg).to_string())); + argv + } + + fn filter_override_env(driver: &str) -> HashMap> { + HashMap::from([ + ("GIT_CONFIG_COUNT".to_string(), Some("3".to_string())), + ( + "GIT_CONFIG_KEY_0".to_string(), + Some(format!("{driver}.clean")), + ), + ("GIT_CONFIG_VALUE_0".to_string(), Some(String::new())), + ( + "GIT_CONFIG_KEY_1".to_string(), + Some(format!("{driver}.process")), + ), + ("GIT_CONFIG_VALUE_1".to_string(), Some(String::new())), + ( + "GIT_CONFIG_KEY_2".to_string(), + Some(format!("{driver}.required")), + ), + ("GIT_CONFIG_VALUE_2".to_string(), Some("false".to_string())), + ]) + } + + fn response(argv: Vec, exit_code: i32, stdout: &str) -> FakeResponse { FakeResponse { - argv: argv.iter().map(|arg| (*arg).to_string()).collect(), + argv, output: WorkspaceCommandOutput { exit_code, stdout: stdout.to_string(), @@ -275,15 +617,32 @@ mod tests { if cfg!(windows) { "NUL" } else { "/dev/null" } } - fn assert_commands(commands: &[WorkspaceCommand], expected: &[&[&str]], cwd: &Path) { + #[cfg(unix)] + fn run_git_setup(cwd: &Path, args: &[&str]) { + let status = ProcessCommand::new("git") + .args(args) + .current_dir(cwd) + .status() + .expect("run git setup command"); + assert!(status.success(), "git setup command failed: {args:?}"); + } + + #[cfg(unix)] + fn write_marker_helper(path: &Path) { + fs::write(path, "#!/bin/sh\nprintf ran >> \"$0.ran\"\nexit 1\n") + .expect("write helper script"); + let mut permissions = fs::metadata(path) + .expect("read helper metadata") + .permissions(); + permissions.set_mode(/*mode*/ 0o755); + fs::set_permissions(path, permissions).expect("make helper executable"); + } + + fn assert_commands(commands: &[WorkspaceCommand], expected: &[Vec], cwd: &Path) { let actual: Vec> = commands .iter() .map(|command| command.argv.clone()) .collect(); - let expected: Vec> = expected - .iter() - .map(|argv| argv.iter().map(|arg| (*arg).to_string()).collect()) - .collect(); assert_eq!(actual, expected); for command in commands { @@ -336,4 +695,44 @@ mod tests { }) } } + + #[cfg(unix)] + struct LocalRunner; + + #[cfg(unix)] + impl WorkspaceCommandExecutor for LocalRunner { + fn run( + &self, + command: WorkspaceCommand, + ) -> Pin< + Box< + dyn Future> + + Send + + '_, + >, + > { + Box::pin(async move { + let mut process = ProcessCommand::new(&command.argv[0]); + process + .args(&command.argv[1..]) + .current_dir(command.cwd.expect("test command cwd")); + for (key, value) in command.env { + match value { + Some(value) => { + process.env(key, value); + } + None => { + process.env_remove(key); + } + } + } + let output = process.output().expect("run test command"); + Ok(WorkspaceCommandOutput { + exit_code: output.status.code().expect("test command exit code"), + stdout: String::from_utf8(output.stdout).expect("utf8 stdout"), + stderr: String::from_utf8(output.stderr).expect("utf8 stderr"), + }) + }) + } + } } diff --git a/codex-rs/tui/src/history_cell/base.rs b/codex-rs/tui/src/history_cell/base.rs index e3b33aa9d1..e9ade84945 100644 --- a/codex-rs/tui/src/history_cell/base.rs +++ b/codex-rs/tui/src/history_cell/base.rs @@ -22,6 +22,35 @@ impl HistoryCell for PlainHistoryCell { plain_lines(self.lines.clone()) } } + +#[derive(Debug)] +pub(crate) struct WebHyperlinkHistoryCell { + lines: Vec>, +} + +impl WebHyperlinkHistoryCell { + pub(crate) fn new(lines: Vec>) -> Self { + Self { lines } + } +} + +impl HistoryCell for WebHyperlinkHistoryCell { + fn display_lines(&self, _width: u16) -> Vec> { + self.lines.clone() + } + + fn display_hyperlink_lines(&self, _width: u16) -> Vec { + crate::terminal_hyperlinks::annotate_web_urls(self.lines.clone()) + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.lines.clone()) + } +} #[derive(Debug)] pub(crate) struct PrefixedWrappedHistoryCell { text: Text<'static>, @@ -86,6 +115,38 @@ impl HistoryCell for CompositeHistoryCell { out } + fn display_hyperlink_lines(&self, width: u16) -> Vec { + let mut out = Vec::new(); + let mut first = true; + for part in &self.parts { + let mut lines = part.display_hyperlink_lines(width); + if !lines.is_empty() { + if !first { + out.push(HyperlinkLine::from("")); + } + out.append(&mut lines); + first = false; + } + } + out + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + let mut out = Vec::new(); + let mut first = true; + for part in &self.parts { + let mut lines = part.transcript_hyperlink_lines(width); + if !lines.is_empty() { + if !first { + out.push(HyperlinkLine::from("")); + } + out.append(&mut lines); + first = false; + } + } + out + } + fn raw_lines(&self) -> Vec> { let mut out: Vec> = Vec::new(); let mut first = true; diff --git a/codex-rs/tui/src/history_cell/mcp.rs b/codex-rs/tui/src/history_cell/mcp.rs index 5b761c1ef5..599a44f092 100644 --- a/codex-rs/tui/src/history_cell/mcp.rs +++ b/codex-rs/tui/src/history_cell/mcp.rs @@ -325,14 +325,17 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { " • No MCP servers configured.".italic().into(), Line::from(vec![ " See the ".into(), - "\u{1b}]8;;https://developers.openai.com/codex/mcp\u{7}MCP docs\u{1b}]8;;\u{7}" - .underlined(), + crate::terminal_hyperlinks::osc8_hyperlink( + "https://developers.openai.com/codex/mcp", + "MCP docs", + ) + .underlined(), " to configure them.".into(), ]) .style(Style::default().add_modifier(Modifier::DIM)), ]; - PlainHistoryCell { lines } + PlainHistoryCell::new(lines) } #[cfg(test)] @@ -358,7 +361,7 @@ pub(crate) fn new_mcp_tools_output( let effective_servers = config.mcp_servers.get().clone(); let mut servers: Vec<_> = effective_servers.iter().collect(); - servers.sort_by(|(a, _), (b, _)| a.cmp(b)); + servers.sort_by_key(|(server, _)| *server); for (server, cfg) in servers { let prefix = qualified_mcp_tool_name_prefix(server); @@ -430,7 +433,7 @@ pub(crate) fn new_mcp_tools_output( && !headers.is_empty() { let mut pairs: Vec<_> = headers.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(name, _)| *name); let display = pairs .into_iter() .map(|(name, _)| format!("{name}=*****")) @@ -442,7 +445,7 @@ pub(crate) fn new_mcp_tools_output( && !headers.is_empty() { let mut pairs: Vec<_> = headers.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(name, _)| *name); let display = pairs .into_iter() .map(|(name, var)| format!("{name}={var}")) diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index 19a6ec2903..ed899f3b2e 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -268,12 +268,20 @@ impl HistoryCell for ReasoningSummaryCell { #[derive(Debug)] pub(crate) struct AgentMessageCell { - lines: Vec>, + lines: Vec, is_first_line: bool, } impl AgentMessageCell { + #[cfg(test)] pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { + Self { + lines: plain_hyperlink_lines(lines), + is_first_line, + } + } + + pub(crate) fn new_hyperlink_lines(lines: Vec, is_first_line: bool) -> Self { Self { lines, is_first_line, @@ -283,6 +291,10 @@ impl AgentMessageCell { impl HistoryCell for AgentMessageCell { fn display_lines(&self, width: u16) -> Vec> { + visible_lines(self.display_hyperlink_lines(width)) + } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { let mut wrapped = Vec::new(); for (index, line) in self.lines.iter().enumerate() { let initial_indent = if index == 0 && self.is_first_line { @@ -293,20 +305,23 @@ impl HistoryCell for AgentMessageCell { let mut subsequent_indent = Line::from(" "); subsequent_indent .spans - .extend(crate::insert_history::leading_whitespace_prefix(line).spans); - let line_wrapped = adaptive_wrap_line( - line, + .extend(crate::insert_history::leading_whitespace_prefix(&line.line).spans); + wrapped.extend(crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines( + std::slice::from_ref(line), RtOptions::new(width as usize) .initial_indent(initial_indent) .subsequent_indent(subsequent_indent), - ); - push_owned_lines(&line_wrapped, &mut wrapped); + )); } wrapped } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) + plain_lines(visible_lines(self.lines.clone())) } fn is_stream_continuation(&self) -> bool { @@ -347,22 +362,32 @@ impl AgentMarkdownCell { impl HistoryCell for AgentMarkdownCell { fn display_lines(&self, width: u16) -> Vec> { + visible_lines(self.display_hyperlink_lines(width)) + } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { let Some(wrap_width) = crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2) else { - return prefix_lines(vec![Line::default()], "• ".dim(), " ".into()); + return prefix_hyperlink_lines( + vec![HyperlinkLine::new(Line::default())], + "• ".dim(), + " ".into(), + ); }; - let mut lines: Vec> = Vec::new(); // Re-render markdown from source at the current width. Reserve 2 columns for the "• " / // " " prefix prepended below. - crate::markdown::append_markdown_agent_with_cwd( + let lines = crate::markdown::render_markdown_agent_with_links_and_cwd( &self.markdown_source, Some(wrap_width), Some(self.cwd.as_path()), - &mut lines, ); - prefix_lines(lines, "• ".dim(), " ".into()) + prefix_hyperlink_lines(lines, "• ".dim(), " ".into()) + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) } fn raw_lines(&self) -> Vec> { @@ -377,12 +402,12 @@ impl HistoryCell for AgentMarkdownCell { /// every delta and cleared when the stream finalizes. #[derive(Debug)] pub(crate) struct StreamingAgentTailCell { - lines: Vec>, + lines: Vec, is_first_line: bool, } impl StreamingAgentTailCell { - pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { + pub(crate) fn new(lines: Vec, is_first_line: bool) -> Self { Self { lines, is_first_line, @@ -391,10 +416,14 @@ impl StreamingAgentTailCell { } impl HistoryCell for StreamingAgentTailCell { - fn display_lines(&self, _width: u16) -> Vec> { + fn display_lines(&self, width: u16) -> Vec> { + visible_lines(self.display_hyperlink_lines(width)) + } + + fn display_hyperlink_lines(&self, _width: u16) -> Vec { // Tail lines are already rendered at the controller's current stream width. // Re-wrapping them here can split table borders and produce malformed in-flight rows. - prefix_lines( + prefix_hyperlink_lines( self.lines.clone(), if self.is_first_line { "• ".dim() @@ -405,8 +434,12 @@ impl HistoryCell for StreamingAgentTailCell { ) } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { - plain_lines(self.display_lines(u16::MAX)) + plain_lines(self.display_lines(/*width*/ u16::MAX)) } fn is_stream_continuation(&self) -> bool { diff --git a/codex-rs/tui/src/history_cell/mod.rs b/codex-rs/tui/src/history_cell/mod.rs index 12788a59f0..9cef3e4fa5 100644 --- a/codex-rs/tui/src/history_cell/mod.rs +++ b/codex-rs/tui/src/history_cell/mod.rs @@ -22,7 +22,6 @@ use crate::exec_command::strip_bash_lc_and_escape; use crate::legacy_core::config::Config; use crate::live_wrap::take_prefix_by_width; use crate::markdown::append_markdown; -use crate::markdown::append_markdown_agent_with_cwd; use crate::motion::MotionMode; use crate::motion::ReducedMotionIndicator; use crate::motion::activity_indicator; @@ -33,6 +32,11 @@ use crate::render::renderable::Renderable; use crate::session_state::ThreadSessionState; use crate::style::proposed_plan_style; use crate::style::user_message_style; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::mark_buffer_hyperlinks; +use crate::terminal_hyperlinks::plain_hyperlink_lines; +use crate::terminal_hyperlinks::prefix_hyperlink_lines; +use crate::terminal_hyperlinks::visible_lines; #[cfg(test)] use crate::test_support::PathBufExt; #[cfg(test)] @@ -189,13 +193,29 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { /// Returns copy-friendly plain logical lines for raw scrollback mode. fn raw_lines(&self) -> Vec>; + /// Returns rich visible lines plus terminal hyperlink metadata. + fn display_hyperlink_lines(&self, width: u16) -> Vec { + plain_hyperlink_lines(self.display_lines(width)) + } + fn display_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec> { match mode { - HistoryRenderMode::Rich => self.display_lines(width), + HistoryRenderMode::Rich => visible_lines(self.display_hyperlink_lines(width)), HistoryRenderMode::Raw => self.raw_lines(), } } + fn display_hyperlink_lines_for_mode( + &self, + width: u16, + mode: HistoryRenderMode, + ) -> Vec { + match mode { + HistoryRenderMode::Rich => self.display_hyperlink_lines(width), + HistoryRenderMode::Raw => plain_hyperlink_lines(self.raw_lines()), + } + } + /// Returns the number of viewport rows needed to render this cell. /// /// The default delegates to `Paragraph::line_count` with @@ -224,13 +244,22 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { self.display_lines(width) } + /// Returns transcript-overlay lines plus terminal hyperlink metadata. + /// + /// Defaults to the plain transcript representation because some cells render different + /// display and transcript content. Rich cells whose transcript mirrors their display should + /// delegate to `display_hyperlink_lines`. + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + plain_hyperlink_lines(self.transcript_lines(width)) + } + /// Returns the number of viewport rows for the transcript overlay. /// /// Uses the same `Paragraph::line_count` measurement as /// `desired_height`. Contains a workaround for a ratatui bug where /// a single whitespace-only line reports 2 rows instead of 1. fn desired_transcript_height(&self, width: u16) -> u16 { - let lines = self.transcript_lines(width); + let lines = visible_lines(self.transcript_hyperlink_lines(width)); // Workaround: ratatui's line_count returns 2 for a single // whitespace-only line. Clamp to 1 in that case. if let [line] = &lines[..] @@ -270,7 +299,8 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { impl Renderable for Box { fn render(&self, area: Rect, buf: &mut Buffer) { - let lines = self.display_lines(area.width); + let hyperlink_lines = self.display_hyperlink_lines(area.width); + let lines = visible_lines(hyperlink_lines.clone()); let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); let y = if area.height == 0 { 0 @@ -284,6 +314,7 @@ impl Renderable for Box { // entire draw area first so stale glyphs from previous frames never linger. Clear.render(area, buf); paragraph.scroll((y, 0)).render(area, buf); + mark_buffer_hyperlinks(buf, area, &hyperlink_lines, usize::from(y)); } fn desired_height(&self, width: u16) -> u16 { HistoryCell::desired_height(self.as_ref(), width) diff --git a/codex-rs/tui/src/history_cell/notices.rs b/codex-rs/tui/src/history_cell/notices.rs index 3344dc45cd..d9dff4f5a0 100644 --- a/codex-rs/tui/src/history_cell/notices.rs +++ b/codex-rs/tui/src/history_cell/notices.rs @@ -52,7 +52,8 @@ impl HistoryCell for UpdateAvailableHistoryCell { .width() .min(usize::from(width.saturating_sub(4))) .max(1); - with_border_with_inner_width(content.lines, inner_width) + let lines = adaptive_wrap_lines(content.lines, RtOptions::new(inner_width)); + with_border_with_inner_width(lines, inner_width) } fn raw_lines(&self) -> Vec> { @@ -70,6 +71,14 @@ impl HistoryCell for UpdateAvailableHistoryCell { Line::from("https://github.com/openai/codex/releases/latest"), ] } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { + crate::terminal_hyperlinks::annotate_web_urls(self.display_lines(width)) + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } } #[allow(clippy::disallowed_methods)] pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell { @@ -128,6 +137,14 @@ impl HistoryCell for CyberPolicyNoticeCell { Line::from(TRUSTED_ACCESS_FOR_CYBER_URL), ] } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { + crate::terminal_hyperlinks::annotate_web_urls(self.display_lines(width)) + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } } #[derive(Debug)] diff --git a/codex-rs/tui/src/history_cell/plans.rs b/codex-rs/tui/src/history_cell/plans.rs index 6ea56195da..6b644f9e36 100644 --- a/codex-rs/tui/src/history_cell/plans.rs +++ b/codex-rs/tui/src/history_cell/plans.rs @@ -9,12 +9,12 @@ use super::*; /// preview-only during streaming. #[derive(Debug)] pub(crate) struct StreamingPlanTailCell { - lines: Vec>, + lines: Vec, is_stream_continuation: bool, } impl StreamingPlanTailCell { - pub(crate) fn new(lines: Vec>, is_stream_continuation: bool) -> Self { + pub(crate) fn new(lines: Vec, is_stream_continuation: bool) -> Self { Self { lines, is_stream_continuation, @@ -24,11 +24,19 @@ impl StreamingPlanTailCell { impl HistoryCell for StreamingPlanTailCell { fn display_lines(&self, _width: u16) -> Vec> { + visible_lines(self.lines.clone()) + } + + fn display_hyperlink_lines(&self, _width: u16) -> Vec { self.lines.clone() } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) + plain_lines(visible_lines(self.lines.clone())) } fn is_stream_continuation(&self) -> bool { @@ -58,11 +66,11 @@ pub(crate) fn new_proposed_plan(plan_markdown: String, cwd: &Path) -> ProposedPl /// Stream cells are display fragments, not source-backed history. They should be replaced by /// `ProposedPlanCell` during consolidation before relying on resize reflow for finalized history. pub(crate) fn new_proposed_plan_stream( - lines: Vec>, + lines: Vec>, is_stream_continuation: bool, ) -> ProposedPlanStreamCell { ProposedPlanStreamCell { - lines, + lines: lines.into_iter().map(Into::into).collect(), is_stream_continuation, } } @@ -85,36 +93,43 @@ pub(crate) struct ProposedPlanCell { /// terminal resize. #[derive(Debug)] pub(crate) struct ProposedPlanStreamCell { - lines: Vec>, + lines: Vec, is_stream_continuation: bool, } impl HistoryCell for ProposedPlanCell { fn display_lines(&self, width: u16) -> Vec> { - let mut lines: Vec> = Vec::new(); - lines.push(vec!["• ".dim(), "Proposed Plan".bold()].into()); - lines.push(Line::from(" ")); + visible_lines(self.display_hyperlink_lines(width)) + } - let mut plan_lines: Vec> = vec![Line::from(" ")]; + fn display_hyperlink_lines(&self, width: u16) -> Vec { + let mut lines = vec![ + HyperlinkLine::new(vec!["• ".dim(), "Proposed Plan".bold()].into()), + HyperlinkLine::new(Line::from(" ")), + ]; + + let mut plan_lines = vec![HyperlinkLine::new(Line::from(" "))]; let plan_style = proposed_plan_style(); let wrap_width = width.saturating_sub(4).max(1) as usize; - let mut body: Vec> = Vec::new(); - append_markdown_agent_with_cwd( + let mut body = crate::markdown::render_markdown_agent_with_links_and_cwd( &self.plan_markdown, Some(wrap_width), Some(self.cwd.as_path()), - &mut body, ); if body.is_empty() { - body.push(Line::from("(empty)".dim().italic())); + body.push(HyperlinkLine::new(Line::from("(empty)".dim().italic()))); } - plan_lines.extend(prefix_lines(body, " ".into(), " ".into())); - plan_lines.push(Line::from(" ")); + plan_lines.extend(prefix_hyperlink_lines(body, " ".into(), " ".into())); + plan_lines.push(HyperlinkLine::new(Line::from(" "))); lines.extend(plan_lines.into_iter().map(|line| line.style(plan_style))); lines } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { raw_lines_from_source(&self.plan_markdown) } @@ -122,11 +137,19 @@ impl HistoryCell for ProposedPlanCell { impl HistoryCell for ProposedPlanStreamCell { fn display_lines(&self, _width: u16) -> Vec> { + visible_lines(self.lines.clone()) + } + + fn display_hyperlink_lines(&self, _width: u16) -> Vec { self.lines.clone() } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) + plain_lines(visible_lines(self.lines.clone())) } fn is_stream_continuation(&self) -> bool { diff --git a/codex-rs/tui/src/history_cell/session.rs b/codex-rs/tui/src/history_cell/session.rs index 46bbb35eed..23e8836454 100644 --- a/codex-rs/tui/src/history_cell/session.rs +++ b/codex-rs/tui/src/history_cell/session.rs @@ -58,7 +58,7 @@ fn with_border_internal( let span_count = line.spans.len(); let mut spans: Vec> = Vec::with_capacity(span_count + 4); spans.push(Span::from("│ ").dim()); - spans.extend(line.into_iter()); + spans.extend(line); if used_width < content_width { spans.push(Span::from(" ".repeat(content_width - used_width)).dim()); } diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap index 1d89a4bd6a..f1c04cec85 100644 --- a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap @@ -1,5 +1,5 @@ --- -source: tui/src/history_cell.rs +source: tui/src/history_cell/tests.rs expression: rendered --- rich before: @@ -10,11 +10,9 @@ rich before: • - first item - second item - ┌──────┬───────┐ - │ Col │ Value │ - ├──────┼───────┤ - │ code │ x = 1 │ - └──────┴───────┘ + Col Value + ━━━━━━ ━━━━━━━ + code x = 1 copy me • Called @@ -48,11 +46,9 @@ rich after: • - first item - second item - ┌──────┬───────┐ - │ Col │ Value │ - ├──────┼───────┤ - │ code │ x = 1 │ - └──────┴───────┘ + Col Value + ━━━━━━ ━━━━━━━ + code x = 1 copy me • Called diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_unix_update_available_history_cell_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_unix_update_available_history_cell_snapshot.snap new file mode 100644 index 0000000000..c8f352dbb4 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_unix_update_available_history_cell_snapshot.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ✨ Update available! 0.0.0 -> 9.9.9 │ +│ Run sh -c 'curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh' to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_windows_update_available_history_cell_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_windows_update_available_history_cell_snapshot.snap new file mode 100644 index 0000000000..fb15a05775 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_windows_update_available_history_cell_snapshot.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ✨ Update available! 0.0.0 -> 9.9.9 │ +│ Run powershell -ExecutionPolicy Bypass -c '$env:CODEX_NON_INTERACTIVE=1; irm │ +│ https://chatgpt.com/codex/install.ps1 | iex' to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 7f846d39fb..76fe17f027 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -278,10 +278,8 @@ fn proposed_plan_cell_renders_markdown_table() { let rendered = render_lines(&plan.display_lines(/*width*/ 80)); assert!( - rendered - .iter() - .any(|line| line.contains('│') || line.contains('┌')), - "expected boxed table in proposed plan output: {rendered:?}" + rendered.iter().any(|line| line.contains('━')), + "expected separated table in proposed plan output: {rendered:?}" ); assert!( !rendered @@ -297,6 +295,47 @@ fn proposed_plan_cell_renders_markdown_table() { ); } +#[test] +fn proposed_plan_cell_preserves_wrapped_table_web_links() { + let destination = "https://example.com/a/very/long/path/to/a/table/artifact"; + let plan = new_proposed_plan( + format!("| Step | URL |\n| --- | --- |\n| Verify | {destination} |\n"), + &test_cwd(), + ); + + let lines = plan.display_hyperlink_lines(/*width*/ 32); + let linked_rows = lines + .iter() + .filter(|line| !line.hyperlinks.is_empty()) + .collect::>(); + + assert!(linked_rows.len() > 1); + assert!(linked_rows.iter().all(|line| { + line.hyperlinks + .iter() + .all(|link| link.destination == destination) + })); +} + +#[test] +fn composite_cell_preserves_child_web_links() { + let destination = "https://chatgpt.com/codex/settings/usage"; + let cell = CompositeHistoryCell::new(vec![ + Box::new(PlainHistoryCell::new(vec![Line::from("/status")])), + Box::new(WebHyperlinkHistoryCell::new(vec![Line::from(destination)])), + ]); + + let lines = cell.display_hyperlink_lines(/*width*/ 80); + + assert_eq!( + lines[2].hyperlinks, + vec![crate::terminal_hyperlinks::TerminalHyperlink { + columns: 0..destination.len(), + destination: destination.to_string(), + }] + ); +} + #[test] fn proposed_plan_cell_unwraps_markdown_fenced_table() { let plan = new_proposed_plan( @@ -308,10 +347,8 @@ fn proposed_plan_cell_unwraps_markdown_fenced_table() { let rendered = render_lines(&plan.display_lines(/*width*/ 80)); assert!( - rendered - .iter() - .any(|line| line.contains('│') || line.contains('┌')), - "expected boxed table for markdown-fenced proposed plan output: {rendered:?}" + rendered.iter().any(|line| line.contains('━')), + "expected separated table for markdown-fenced proposed plan output: {rendered:?}" ); assert!( !rendered.iter().any(|line| line.trim() == "```markdown"), @@ -806,6 +843,7 @@ async fn mcp_tools_output_lists_tools_for_hyphenated_server_names() { fn mcp_tools_output_from_statuses_renders_status_only_servers() { let statuses = vec![McpServerStatus { name: "plugin_docs".to_string(), + server_info: None, tools: HashMap::from([( "lookup".to_string(), Tool { @@ -835,6 +873,7 @@ fn mcp_tools_output_from_statuses_renders_status_only_servers() { fn mcp_tools_output_from_statuses_renders_verbose_inventory() { let statuses = vec![McpServerStatus { name: "plugin_docs".to_string(), + server_info: None, tools: HashMap::from([( "lookup".to_string(), Tool { @@ -1027,6 +1066,24 @@ fn web_search_history_cell_snapshot() { insta::assert_snapshot!(rendered); } +#[test] +fn standalone_unix_update_available_history_cell_snapshot() { + let cell = + UpdateAvailableHistoryCell::new("9.9.9".to_string(), Some(UpdateAction::StandaloneUnix)); + let rendered = render_lines(&cell.display_lines(/*width*/ 110)).join("\n"); + + insta::assert_snapshot!(rendered); +} + +#[test] +fn standalone_windows_update_available_history_cell_snapshot() { + let cell = + UpdateAvailableHistoryCell::new("9.9.9".to_string(), Some(UpdateAction::StandaloneWindows)); + let rendered = render_lines(&cell.display_lines(/*width*/ 110)).join("\n"); + + insta::assert_snapshot!(rendered); +} + #[test] fn web_search_history_cell_wraps_with_indented_continuation() { let query = "example search query with several generic words to exercise wrapping".to_string(); diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 40da6ccbcc..760b516421 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -7,6 +7,11 @@ use std::fmt; use std::io; use std::io::Write; +use crate::render::line_utils::line_to_static; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::decorate_spans; +use crate::terminal_hyperlinks::plain_hyperlink_lines; +use crate::terminal_hyperlinks::remap_wrapped_line; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::line_contains_url_like; @@ -85,6 +90,23 @@ pub(crate) fn insert_history_lines_with_mode_and_wrap_policy( mode: InsertHistoryMode, wrap_policy: HistoryLineWrapPolicy, ) -> io::Result<()> +where + B: Backend + Write, +{ + insert_history_hyperlink_lines_with_mode_and_wrap_policy( + terminal, + plain_hyperlink_lines(lines.iter().map(line_to_static).collect()), + mode, + wrap_policy, + ) +} + +pub(crate) fn insert_history_hyperlink_lines_with_mode_and_wrap_policy( + terminal: &mut crate::custom_terminal::Terminal, + lines: Vec, + mode: InsertHistoryMode, + wrap_policy: HistoryLineWrapPolicy, +) -> io::Result<()> where B: Backend + Write, { @@ -113,13 +135,21 @@ where let line_wrapped = match wrap_policy { HistoryLineWrapPolicy::Terminal => vec![line.clone()], HistoryLineWrapPolicy::PreWrap - if line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line) => + if line_contains_url_like(&line.line) + && !line_has_mixed_url_and_non_url_tokens(&line.line) => { vec![line.clone()] } - HistoryLineWrapPolicy::PreWrap => adaptive_wrap_line( + HistoryLineWrapPolicy::PreWrap => remap_wrapped_line( line, - RtOptions::new(wrap_width).subsequent_indent(leading_whitespace_prefix(line)), + adaptive_wrap_line( + &line.line, + RtOptions::new(wrap_width) + .subsequent_indent(leading_whitespace_prefix(&line.line)), + ) + .into_iter() + .map(|line| line_to_static(&line)) + .collect(), ), }; wrapped_rows += line_wrapped @@ -249,7 +279,11 @@ pub(crate) fn leading_whitespace_prefix(line: &Line<'_>) -> Line<'static> { /// Render a single wrapped history line: clear continuation rows for wide lines, /// set foreground/background colors, and write styled spans. Caller is responsible /// for cursor positioning and any leading `\r\n`. -fn write_history_line(writer: &mut W, line: &Line, wrap_width: usize) -> io::Result<()> { +fn write_history_line( + writer: &mut W, + line: &HyperlinkLine, + wrap_width: usize, +) -> io::Result<()> { let physical_rows = line.width().max(1).div_ceil(wrap_width) as u16; if physical_rows > 1 { queue!(writer, SavePosition)?; @@ -262,11 +296,13 @@ fn write_history_line(writer: &mut W, line: &Line, wrap_width: usize) queue!( writer, SetColors(Colors::new( - line.style + line.line + .style .fg .map(std::convert::Into::into) .unwrap_or(CColor::Reset), - line.style + line.line + .style .bg .map(std::convert::Into::into) .unwrap_or(CColor::Reset) @@ -276,14 +312,20 @@ fn write_history_line(writer: &mut W, line: &Line, wrap_width: usize) // Merge line-level style into each span so that ANSI colors reflect // line styles (e.g., blockquotes with green fg). let merged_spans: Vec = line + .line .spans .iter() .map(|s| Span { - style: s.style.patch(line.style), + style: s.style.patch(line.line.style), content: s.content.clone(), }) .collect(); - write_spans(writer, merged_spans.iter()) + let merged_line = HyperlinkLine { + line: Line::from(merged_spans), + hyperlinks: line.hyperlinks.clone(), + }; + let decorated = decorate_spans(&merged_line); + write_spans(writer, decorated.iter()) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -470,6 +512,19 @@ mod tests { ); } + #[test] + fn writes_semantic_web_link_without_changing_visible_text() { + let destination = "https://example.com/long/path"; + let line = crate::terminal_hyperlinks::annotate_web_urls_in_line(Line::from(destination)); + let mut actual = Vec::new(); + + write_history_line(&mut actual, &line, /*wrap_width*/ 80).expect("write history line"); + + let output = String::from_utf8(actual).expect("UTF-8 terminal output"); + assert!(output.contains("\x1b]8;;https://example.com/long/path\x07")); + assert_eq!(line.line.spans[0].content, destination); + } + #[test] fn vt100_blockquote_line_emits_green_fg() { // Set up a small off-screen terminal diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 533e28f3a2..7bb554a257 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -46,6 +46,7 @@ pub(crate) struct RuntimeKeymap { pub(crate) editor: EditorKeymap, pub(crate) vim_normal: VimNormalKeymap, pub(crate) vim_operator: VimOperatorKeymap, + pub(crate) vim_text_object: VimTextObjectKeymap, pub(crate) pager: PagerKeymap, pub(crate) list: ListKeymap, pub(crate) approval: ApprovalKeymap, @@ -77,6 +78,8 @@ pub(crate) struct AppKeymap { /// handler code, not here. #[derive(Clone, Debug)] pub(crate) struct ChatKeymap { + /// Interrupt the active turn. + pub(crate) interrupt_turn: Vec, /// Decrease the active reasoning effort. pub(crate) decrease_reasoning_effort: Vec, /// Increase the active reasoning effort. @@ -156,10 +159,12 @@ pub(crate) struct VimNormalKeymap { pub(crate) move_line_end: Vec, pub(crate) delete_char: Vec, pub(crate) delete_to_line_end: Vec, + pub(crate) change_to_line_end: Vec, pub(crate) yank_line: Vec, pub(crate) paste_after: Vec, pub(crate) start_delete_operator: Vec, pub(crate) start_yank_operator: Vec, + pub(crate) start_change_operator: Vec, pub(crate) cancel_operator: Vec, } @@ -182,6 +187,22 @@ pub(crate) struct VimOperatorKeymap { pub(crate) motion_word_end: Vec, pub(crate) motion_line_start: Vec, pub(crate) motion_line_end: Vec, + pub(crate) select_inner_text_object: Vec, + pub(crate) select_around_text_object: Vec, + pub(crate) cancel: Vec, +} + +/// Vim text-object keybindings active after an operator plus inner/around prefix. +#[derive(Clone, Debug, Default)] +pub(crate) struct VimTextObjectKeymap { + pub(crate) word: Vec, + pub(crate) big_word: Vec, + pub(crate) parentheses: Vec, + pub(crate) brackets: Vec, + pub(crate) braces: Vec, + pub(crate) double_quote: Vec, + pub(crate) single_quote: Vec, + pub(crate) backtick: Vec, pub(crate) cancel: Vec, } @@ -401,6 +422,11 @@ impl RuntimeKeymap { }; let chat = ChatKeymap { + interrupt_turn: resolve_bindings( + keymap.chat.interrupt_turn.as_ref(), + &defaults.chat.interrupt_turn, + "tui.keymap.chat.interrupt_turn", + )?, decrease_reasoning_effort: resolve_bindings( keymap.chat.decrease_reasoning_effort.as_ref(), &defaults.chat.decrease_reasoning_effort, @@ -451,7 +477,7 @@ impl RuntimeKeymap { yank: resolve_local!(keymap, defaults, editor, yank), }; - let vim_normal = VimNormalKeymap { + let mut vim_normal = VimNormalKeymap { enter_insert: resolve_local!(keymap, defaults, vim_normal, enter_insert), append_after_cursor: resolve_local!(keymap, defaults, vim_normal, append_after_cursor), append_line_end: resolve_local!(keymap, defaults, vim_normal, append_line_end), @@ -469,6 +495,7 @@ impl RuntimeKeymap { move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end), delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char), delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end), + change_to_line_end: resolve_local!(keymap, defaults, vim_normal, change_to_line_end), yank_line: resolve_local!(keymap, defaults, vim_normal, yank_line), paste_after: resolve_local!(keymap, defaults, vim_normal, paste_after), start_delete_operator: resolve_local!( @@ -478,10 +505,113 @@ impl RuntimeKeymap { start_delete_operator ), start_yank_operator: resolve_local!(keymap, defaults, vim_normal, start_yank_operator), + start_change_operator: resolve_local!( + keymap, + defaults, + vim_normal, + start_change_operator + ), cancel_operator: resolve_local!(keymap, defaults, vim_normal, cancel_operator), }; - let vim_operator = VimOperatorKeymap { + let configured_vim_normal_bindings_to_preserve = configured_bindings_to_preserve([ + ( + keymap.vim_normal.enter_insert.as_ref(), + vim_normal.enter_insert.as_slice(), + ), + ( + keymap.vim_normal.append_after_cursor.as_ref(), + vim_normal.append_after_cursor.as_slice(), + ), + ( + keymap.vim_normal.append_line_end.as_ref(), + vim_normal.append_line_end.as_slice(), + ), + ( + keymap.vim_normal.insert_line_start.as_ref(), + vim_normal.insert_line_start.as_slice(), + ), + ( + keymap.vim_normal.open_line_below.as_ref(), + vim_normal.open_line_below.as_slice(), + ), + ( + keymap.vim_normal.open_line_above.as_ref(), + vim_normal.open_line_above.as_slice(), + ), + ( + keymap.vim_normal.move_left.as_ref(), + vim_normal.move_left.as_slice(), + ), + ( + keymap.vim_normal.move_right.as_ref(), + vim_normal.move_right.as_slice(), + ), + ( + keymap.vim_normal.move_up.as_ref(), + vim_normal.move_up.as_slice(), + ), + ( + keymap.vim_normal.move_down.as_ref(), + vim_normal.move_down.as_slice(), + ), + ( + keymap.vim_normal.move_word_forward.as_ref(), + vim_normal.move_word_forward.as_slice(), + ), + ( + keymap.vim_normal.move_word_backward.as_ref(), + vim_normal.move_word_backward.as_slice(), + ), + ( + keymap.vim_normal.move_word_end.as_ref(), + vim_normal.move_word_end.as_slice(), + ), + ( + keymap.vim_normal.move_line_start.as_ref(), + vim_normal.move_line_start.as_slice(), + ), + ( + keymap.vim_normal.move_line_end.as_ref(), + vim_normal.move_line_end.as_slice(), + ), + ( + keymap.vim_normal.delete_char.as_ref(), + vim_normal.delete_char.as_slice(), + ), + ( + keymap.vim_normal.delete_to_line_end.as_ref(), + vim_normal.delete_to_line_end.as_slice(), + ), + ( + keymap.vim_normal.yank_line.as_ref(), + vim_normal.yank_line.as_slice(), + ), + ( + keymap.vim_normal.paste_after.as_ref(), + vim_normal.paste_after.as_slice(), + ), + ( + keymap.vim_normal.start_delete_operator.as_ref(), + vim_normal.start_delete_operator.as_slice(), + ), + ( + keymap.vim_normal.start_yank_operator.as_ref(), + vim_normal.start_yank_operator.as_slice(), + ), + ( + keymap.vim_normal.cancel_operator.as_ref(), + vim_normal.cancel_operator.as_slice(), + ), + ]); + + if keymap.vim_normal.start_change_operator.is_none() { + vim_normal + .start_change_operator + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } + + let mut vim_operator = VimOperatorKeymap { delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line), yank_line: resolve_local!(keymap, defaults, vim_operator, yank_line), motion_left: resolve_local!(keymap, defaults, vim_operator, motion_left), @@ -503,9 +633,95 @@ impl RuntimeKeymap { motion_word_end: resolve_local!(keymap, defaults, vim_operator, motion_word_end), motion_line_start: resolve_local!(keymap, defaults, vim_operator, motion_line_start), motion_line_end: resolve_local!(keymap, defaults, vim_operator, motion_line_end), + select_inner_text_object: resolve_local!( + keymap, + defaults, + vim_operator, + select_inner_text_object + ), + select_around_text_object: resolve_local!( + keymap, + defaults, + vim_operator, + select_around_text_object + ), cancel: resolve_local!(keymap, defaults, vim_operator, cancel), }; + let configured_vim_operator_bindings_to_preserve = configured_bindings_to_preserve([ + ( + keymap.vim_operator.delete_line.as_ref(), + vim_operator.delete_line.as_slice(), + ), + ( + keymap.vim_operator.yank_line.as_ref(), + vim_operator.yank_line.as_slice(), + ), + ( + keymap.vim_operator.motion_left.as_ref(), + vim_operator.motion_left.as_slice(), + ), + ( + keymap.vim_operator.motion_right.as_ref(), + vim_operator.motion_right.as_slice(), + ), + ( + keymap.vim_operator.motion_up.as_ref(), + vim_operator.motion_up.as_slice(), + ), + ( + keymap.vim_operator.motion_down.as_ref(), + vim_operator.motion_down.as_slice(), + ), + ( + keymap.vim_operator.motion_word_forward.as_ref(), + vim_operator.motion_word_forward.as_slice(), + ), + ( + keymap.vim_operator.motion_word_backward.as_ref(), + vim_operator.motion_word_backward.as_slice(), + ), + ( + keymap.vim_operator.motion_word_end.as_ref(), + vim_operator.motion_word_end.as_slice(), + ), + ( + keymap.vim_operator.motion_line_start.as_ref(), + vim_operator.motion_line_start.as_slice(), + ), + ( + keymap.vim_operator.motion_line_end.as_ref(), + vim_operator.motion_line_end.as_slice(), + ), + ( + keymap.vim_operator.cancel.as_ref(), + vim_operator.cancel.as_slice(), + ), + ]); + + if keymap.vim_operator.select_inner_text_object.is_none() { + vim_operator + .select_inner_text_object + .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); + } + if keymap.vim_operator.select_around_text_object.is_none() { + vim_operator + .select_around_text_object + .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); + } + + let vim_text_object = VimTextObjectKeymap { + word: resolve_local!(keymap, defaults, vim_text_object, word), + big_word: resolve_local!(keymap, defaults, vim_text_object, big_word), + parentheses: resolve_local!(keymap, defaults, vim_text_object, parentheses), + brackets: resolve_local!(keymap, defaults, vim_text_object, brackets), + braces: resolve_local!(keymap, defaults, vim_text_object, braces), + double_quote: resolve_local!(keymap, defaults, vim_text_object, double_quote), + single_quote: resolve_local!(keymap, defaults, vim_text_object, single_quote), + backtick: resolve_local!(keymap, defaults, vim_text_object, backtick), + cancel: resolve_local!(keymap, defaults, vim_text_object, cancel), + }; + let pager = PagerKeymap { scroll_up: resolve_local!(keymap, defaults, pager, scroll_up), scroll_down: resolve_local!(keymap, defaults, pager, scroll_down), @@ -534,8 +750,7 @@ impl RuntimeKeymap { let list_move_down = resolve_local!(keymap, defaults, list, move_down); let list_accept = resolve_local!(keymap, defaults, list, accept); let list_cancel = resolve_local!(keymap, defaults, list, cancel); - let mut configured_bindings_to_preserve = Vec::new(); - for (configured, resolved) in [ + let configured_bindings_to_preserve = configured_bindings_to_preserve([ ( keymap.global.open_transcript.as_ref(), app.open_transcript.as_slice(), @@ -591,51 +806,42 @@ impl RuntimeKeymap { approval.decline.as_slice(), ), (keymap.approval.cancel.as_ref(), approval.cancel.as_slice()), - ] { - if configured.is_none() { - continue; - } - for binding in resolved { - if !configured_bindings_to_preserve.contains(binding) { - configured_bindings_to_preserve.push(*binding); - } - } - } + ]); let list = ListKeymap { move_up: list_move_up, move_down: list_move_down, - move_left: resolve_new_list_bindings( + move_left: resolve_new_default_bindings( keymap.list.move_left.as_ref(), &defaults.list.move_left, &configured_bindings_to_preserve, "tui.keymap.list.move_left", )?, - move_right: resolve_new_list_bindings( + move_right: resolve_new_default_bindings( keymap.list.move_right.as_ref(), &defaults.list.move_right, &configured_bindings_to_preserve, "tui.keymap.list.move_right", )?, - page_up: resolve_new_list_bindings( + page_up: resolve_new_default_bindings( keymap.list.page_up.as_ref(), &defaults.list.page_up, &configured_bindings_to_preserve, "tui.keymap.list.page_up", )?, - page_down: resolve_new_list_bindings( + page_down: resolve_new_default_bindings( keymap.list.page_down.as_ref(), &defaults.list.page_down, &configured_bindings_to_preserve, "tui.keymap.list.page_down", )?, - jump_top: resolve_new_list_bindings( + jump_top: resolve_new_default_bindings( keymap.list.jump_top.as_ref(), &defaults.list.jump_top, &configured_bindings_to_preserve, "tui.keymap.list.jump_top", )?, - jump_bottom: resolve_new_list_bindings( + jump_bottom: resolve_new_default_bindings( keymap.list.jump_bottom.as_ref(), &defaults.list.jump_bottom, &configured_bindings_to_preserve, @@ -652,6 +858,7 @@ impl RuntimeKeymap { editor, vim_normal, vim_operator, + vim_text_object, pager, list, approval, @@ -678,6 +885,7 @@ impl RuntimeKeymap { toggle_raw_output: default_bindings![alt(KeyCode::Char('r'))], }, chat: ChatKeymap { + interrupt_turn: default_bindings![plain(KeyCode::Esc)], decrease_reasoning_effort: default_bindings![alt(KeyCode::Char(','))], increase_reasoning_effort: default_bindings![alt(KeyCode::Char('.'))], edit_queued_message: default_bindings![alt(KeyCode::Up), shift(KeyCode::Left)], @@ -786,10 +994,15 @@ impl RuntimeKeymap { shift(KeyCode::Char('d')), plain(KeyCode::Char('D')) ], + change_to_line_end: default_bindings![ + shift(KeyCode::Char('c')), + plain(KeyCode::Char('C')) + ], yank_line: default_bindings![shift(KeyCode::Char('y')), plain(KeyCode::Char('Y'))], paste_after: default_bindings![plain(KeyCode::Char('p'))], start_delete_operator: default_bindings![plain(KeyCode::Char('d'))], start_yank_operator: default_bindings![plain(KeyCode::Char('y'))], + start_change_operator: default_bindings![plain(KeyCode::Char('c'))], cancel_operator: default_bindings![plain(KeyCode::Esc)], }, vim_operator: VimOperatorKeymap { @@ -807,6 +1020,35 @@ impl RuntimeKeymap { plain(KeyCode::Char('$')), shift(KeyCode::Char('$')) ], + select_inner_text_object: default_bindings![plain(KeyCode::Char('i'))], + select_around_text_object: default_bindings![plain(KeyCode::Char('a'))], + cancel: default_bindings![plain(KeyCode::Esc)], + }, + vim_text_object: VimTextObjectKeymap { + word: default_bindings![plain(KeyCode::Char('w'))], + big_word: default_bindings![shift(KeyCode::Char('w')), plain(KeyCode::Char('W'))], + parentheses: default_bindings![ + plain(KeyCode::Char('(')), + shift(KeyCode::Char('(')), + plain(KeyCode::Char(')')), + shift(KeyCode::Char(')')), + plain(KeyCode::Char('b')) + ], + brackets: default_bindings![plain(KeyCode::Char('[')), plain(KeyCode::Char(']'))], + braces: default_bindings![ + plain(KeyCode::Char('{')), + shift(KeyCode::Char('{')), + plain(KeyCode::Char('}')), + shift(KeyCode::Char('}')), + shift(KeyCode::Char('b')), + plain(KeyCode::Char('B')) + ], + double_quote: default_bindings![ + plain(KeyCode::Char('"')), + shift(KeyCode::Char('"')) + ], + single_quote: default_bindings![plain(KeyCode::Char('\''))], + backtick: default_bindings![plain(KeyCode::Char('`'))], cancel: default_bindings![plain(KeyCode::Esc)], }, pager: PagerKeymap { @@ -893,6 +1135,7 @@ impl RuntimeKeymap { ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), + ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -935,6 +1178,7 @@ impl RuntimeKeymap { ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), + ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -963,6 +1207,11 @@ impl RuntimeKeymap { ), ], MAIN_RESERVED_BINDINGS, + [( + "chat.interrupt_turn", + "fixed.backtrack", + key_hint::plain(KeyCode::Esc), + )], )?; validate_no_shadow_with_allowed_overlaps( @@ -1015,6 +1264,18 @@ impl RuntimeKeymap { )], )?; + // The request-user-input overlay consumes turn interruption before + // configurable question navigation reaches its list handler. + validate_no_shadow_with_allowed_overlaps( + "request_user_input", + [("chat.interrupt_turn", self.chat.interrupt_turn.as_slice())], + [ + ("list.move_left", self.list.move_left.as_slice()), + ("list.move_right", self.list.move_right.as_slice()), + ], + [], + )?; + // While the composer is focused, these main-surface handlers always // consume matching keys before the event reaches the textarea editor. validate_no_shadow_with_allowed_overlaps( @@ -1027,6 +1288,7 @@ impl RuntimeKeymap { ), ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), + ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -1176,6 +1438,10 @@ impl RuntimeKeymap { "delete_to_line_end", self.vim_normal.delete_to_line_end.as_slice(), ), + ( + "change_to_line_end", + self.vim_normal.change_to_line_end.as_slice(), + ), ("yank_line", self.vim_normal.yank_line.as_slice()), ("paste_after", self.vim_normal.paste_after.as_slice()), ( @@ -1186,6 +1452,10 @@ impl RuntimeKeymap { "start_yank_operator", self.vim_normal.start_yank_operator.as_slice(), ), + ( + "start_change_operator", + self.vim_normal.start_change_operator.as_slice(), + ), ( "cancel_operator", self.vim_normal.cancel_operator.as_slice(), @@ -1222,10 +1492,33 @@ impl RuntimeKeymap { "motion_line_end", self.vim_operator.motion_line_end.as_slice(), ), + ( + "select_inner_text_object", + self.vim_operator.select_inner_text_object.as_slice(), + ), + ( + "select_around_text_object", + self.vim_operator.select_around_text_object.as_slice(), + ), ("cancel", self.vim_operator.cancel.as_slice()), ], )?; + validate_unique( + "vim_text_object", + [ + ("word", self.vim_text_object.word.as_slice()), + ("big_word", self.vim_text_object.big_word.as_slice()), + ("parentheses", self.vim_text_object.parentheses.as_slice()), + ("brackets", self.vim_text_object.brackets.as_slice()), + ("braces", self.vim_text_object.braces.as_slice()), + ("double_quote", self.vim_text_object.double_quote.as_slice()), + ("single_quote", self.vim_text_object.single_quote.as_slice()), + ("backtick", self.vim_text_object.backtick.as_slice()), + ("cancel", self.vim_text_object.cancel.as_slice()), + ], + )?; + validate_unique( "pager", [ @@ -1257,6 +1550,7 @@ impl RuntimeKeymap { ("close_transcript", self.pager.close_transcript.as_slice()), ], TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS, + [], )?; validate_unique( @@ -1410,10 +1704,11 @@ See the Codex keymap documentation for supported actions and examples." Ok(()) } -fn validate_no_reserved( +fn validate_no_reserved( context: &str, pairs: [(&'static str, &[KeyBinding]); N], reserved: &[(&'static str, KeyBinding)], + allowed_overlaps: [(&'static str, &'static str, KeyBinding); A], ) -> Result<(), String> { for (action, bindings) in pairs { for binding in bindings { @@ -1422,6 +1717,15 @@ fn validate_no_reserved( .iter() .find(|(_, reserved_binding)| reserved_binding.parts() == key) { + if allowed_overlaps.iter().any( + |(allowed_action, allowed_reserved_action, allowed_binding)| { + *allowed_action == action + && *allowed_reserved_action == *reserved_action + && allowed_binding.parts() == key + }, + ) { + continue; + } return Err(format!( "Ambiguous `tui.keymap.{context}` bindings: `{action}` uses a key reserved by `{reserved_action}`. \ Set a different key in `~/.codex/config.toml` and retry. \ @@ -1514,7 +1818,24 @@ fn resolve_bindings( parse_bindings(spec, path) } -fn resolve_new_list_bindings( +fn configured_bindings_to_preserve( + pairs: [(Option<&KeybindingsSpec>, &[KeyBinding]); N], +) -> Vec { + let mut configured_bindings = Vec::new(); + for (configured, resolved) in pairs { + if configured.is_none() { + continue; + } + for binding in resolved { + if !configured_bindings.contains(binding) { + configured_bindings.push(*binding); + } + } + } + configured_bindings +} + +fn resolve_new_default_bindings( configured: Option<&KeybindingsSpec>, fallback: &[KeyBinding], configured_bindings_to_preserve: &[KeyBinding], @@ -1794,6 +2115,10 @@ mod tests { vec![key_hint::ctrl(KeyCode::Char('l'))] ); assert_eq!(runtime.app.toggle_fast_mode, Vec::new()); + assert_eq!( + runtime.chat.interrupt_turn, + vec![key_hint::plain(KeyCode::Esc)] + ); assert_eq!( runtime.chat.decrease_reasoning_effort, vec![key_hint::alt(KeyCode::Char(','))] @@ -1963,6 +2288,58 @@ mod tests { expect_conflict(&keymap, "list.jump_top", "approval.approve"); } + #[test] + fn configured_legacy_vim_normal_bindings_prune_new_change_operator_default() { + let mut keymap = TuiKeymap::default(); + keymap.vim_normal.move_left = Some(one("c")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!( + runtime.vim_normal.move_left, + vec![key_hint::plain(KeyCode::Char('c'))] + ); + assert_eq!(runtime.vim_normal.start_change_operator, Vec::new()); + } + + #[test] + fn explicit_new_vim_normal_binding_still_conflicts_with_legacy_binding() { + let mut keymap = TuiKeymap::default(); + keymap.vim_normal.move_left = Some(one("c")); + keymap.vim_normal.start_change_operator = Some(one("c")); + + expect_conflict(&keymap, "move_left", "start_change_operator"); + } + + #[test] + fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() { + let mut keymap = TuiKeymap::default(); + keymap.vim_operator.motion_left = Some(one("i")); + keymap.vim_operator.motion_right = Some(one("a")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!( + runtime.vim_operator.motion_left, + vec![key_hint::plain(KeyCode::Char('i'))] + ); + assert_eq!( + runtime.vim_operator.motion_right, + vec![key_hint::plain(KeyCode::Char('a'))] + ); + assert_eq!(runtime.vim_operator.select_inner_text_object, Vec::new()); + assert_eq!(runtime.vim_operator.select_around_text_object, Vec::new()); + } + + #[test] + fn explicit_new_vim_operator_binding_still_conflicts_with_legacy_binding() { + let mut keymap = TuiKeymap::default(); + keymap.vim_operator.motion_left = Some(one("i")); + keymap.vim_operator.select_inner_text_object = Some(one("i")); + + expect_conflict(&keymap, "motion_left", "select_inner_text_object"); + } + #[test] fn vim_normal_defaults_include_insert_and_arrow_aliases() { let runtime = RuntimeKeymap::defaults(); @@ -2164,6 +2541,44 @@ mod tests { expect_conflict(&keymap, "composer.submit", "fixed.paste_image"); } + #[test] + fn interrupt_turn_allows_backtrack_escape_and_can_be_remapped_or_unbound() { + let mut keymap = TuiKeymap::default(); + let runtime = RuntimeKeymap::from_config(&keymap).expect("default keymap should parse"); + assert_eq!( + runtime.chat.interrupt_turn, + vec![key_hint::plain(KeyCode::Esc)] + ); + + keymap.chat.interrupt_turn = Some(one("f12")); + let runtime = RuntimeKeymap::from_config(&keymap).expect("remapped keymap should parse"); + assert_eq!( + runtime.chat.interrupt_turn, + vec![key_hint::plain(KeyCode::F(12))] + ); + + keymap.chat.interrupt_turn = Some(KeybindingsSpec::Many(vec![])); + let runtime = RuntimeKeymap::from_config(&keymap).expect("unbound keymap should parse"); + assert!(runtime.chat.interrupt_turn.is_empty()); + } + + #[test] + fn interrupt_turn_rejects_other_fixed_shortcuts() { + let mut keymap = TuiKeymap::default(); + keymap.chat.interrupt_turn = Some(one("ctrl-v")); + + expect_conflict(&keymap, "chat.interrupt_turn", "fixed.paste_image"); + } + + #[test] + fn interrupt_turn_rejects_request_user_input_question_navigation_bindings() { + let mut keymap = TuiKeymap::default(); + keymap.chat.interrupt_turn = Some(one("f12")); + keymap.list.move_right = Some(one("f12")); + + expect_conflict(&keymap, "chat.interrupt_turn", "list.move_right"); + } + #[test] fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() { let mut keymap = TuiKeymap::default(); diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index f67ea20cb0..8c1292aeb1 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -1009,6 +1009,7 @@ mod tests { actions, vec![ "Composer.submit", + "Chat.interrupt_turn", "Editor.insert_newline", "Composer.queue", "Global.open_external_editor", diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 9ce827b8a9..87d65b225f 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -93,6 +93,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("global", "Global", "toggle_vim_mode", "Turn Vim composer mode on or off."), gated_action("global", "Global", "toggle_fast_mode", "Turn Fast mode on or off.", KeymapActionFeature::FastMode), action("global", "Global", "toggle_raw_output", "Toggle raw scrollback mode."), + action("chat", "Chat", "interrupt_turn", "Interrupt the active turn."), action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."), action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."), action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."), @@ -135,10 +136,12 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_normal", "Vim normal", "move_line_end", "Move to the end of the line."), action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."), action("vim_normal", "Vim normal", "delete_to_line_end", "Delete from cursor to end of line."), + action("vim_normal", "Vim normal", "change_to_line_end", "Change from cursor to end of line and enter insert mode."), action("vim_normal", "Vim normal", "yank_line", "Yank the entire line."), action("vim_normal", "Vim normal", "paste_after", "Paste after the cursor."), action("vim_normal", "Vim normal", "start_delete_operator", "Begin a delete operator and wait for a motion."), action("vim_normal", "Vim normal", "start_yank_operator", "Begin a yank operator and wait for a motion."), + action("vim_normal", "Vim normal", "start_change_operator", "Begin a change operator and wait for a text object."), action("vim_normal", "Vim normal", "cancel_operator", "Cancel a pending Vim operator."), action("vim_operator", "Vim operator", "delete_line", "Repeat delete operator to delete the whole line."), action("vim_operator", "Vim operator", "yank_line", "Repeat yank operator to yank the whole line."), @@ -151,7 +154,18 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_operator", "Vim operator", "motion_word_end", "Operator motion to end of word."), action("vim_operator", "Vim operator", "motion_line_start", "Operator motion to line start."), action("vim_operator", "Vim operator", "motion_line_end", "Operator motion to line end."), + action("vim_operator", "Vim operator", "select_inner_text_object", "Select an inner text object."), + action("vim_operator", "Vim operator", "select_around_text_object", "Select an around text object."), action("vim_operator", "Vim operator", "cancel", "Cancel the pending operator."), + action("vim_text_object", "Vim text object", "word", "Target the current word."), + action("vim_text_object", "Vim text object", "big_word", "Target the current WORD."), + action("vim_text_object", "Vim text object", "parentheses", "Target enclosing parentheses."), + action("vim_text_object", "Vim text object", "brackets", "Target enclosing brackets."), + action("vim_text_object", "Vim text object", "braces", "Target enclosing braces."), + action("vim_text_object", "Vim text object", "double_quote", "Target enclosing double quotes."), + action("vim_text_object", "Vim text object", "single_quote", "Target enclosing single quotes."), + action("vim_text_object", "Vim text object", "backtick", "Target enclosing backticks."), + action("vim_text_object", "Vim text object", "cancel", "Cancel the pending text object."), action("pager", "Pager", "scroll_up", "Scroll up by one row."), action("pager", "Pager", "scroll_down", "Scroll down by one row."), action("pager", "Pager", "page_up", "Scroll up by one page."), @@ -221,6 +235,7 @@ pub(super) fn binding_slot<'a>( ("global", "toggle_vim_mode") => Some(&mut keymap.global.toggle_vim_mode), ("global", "toggle_fast_mode") => Some(&mut keymap.global.toggle_fast_mode), ("global", "toggle_raw_output") => Some(&mut keymap.global.toggle_raw_output), + ("chat", "interrupt_turn") => Some(&mut keymap.chat.interrupt_turn), ("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort), ("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort), ("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message), @@ -263,10 +278,12 @@ pub(super) fn binding_slot<'a>( ("vim_normal", "move_line_end") => Some(&mut keymap.vim_normal.move_line_end), ("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char), ("vim_normal", "delete_to_line_end") => Some(&mut keymap.vim_normal.delete_to_line_end), + ("vim_normal", "change_to_line_end") => Some(&mut keymap.vim_normal.change_to_line_end), ("vim_normal", "yank_line") => Some(&mut keymap.vim_normal.yank_line), ("vim_normal", "paste_after") => Some(&mut keymap.vim_normal.paste_after), ("vim_normal", "start_delete_operator") => Some(&mut keymap.vim_normal.start_delete_operator), ("vim_normal", "start_yank_operator") => Some(&mut keymap.vim_normal.start_yank_operator), + ("vim_normal", "start_change_operator") => Some(&mut keymap.vim_normal.start_change_operator), ("vim_normal", "cancel_operator") => Some(&mut keymap.vim_normal.cancel_operator), ("vim_operator", "delete_line") => Some(&mut keymap.vim_operator.delete_line), ("vim_operator", "yank_line") => Some(&mut keymap.vim_operator.yank_line), @@ -279,7 +296,18 @@ pub(super) fn binding_slot<'a>( ("vim_operator", "motion_word_end") => Some(&mut keymap.vim_operator.motion_word_end), ("vim_operator", "motion_line_start") => Some(&mut keymap.vim_operator.motion_line_start), ("vim_operator", "motion_line_end") => Some(&mut keymap.vim_operator.motion_line_end), + ("vim_operator", "select_inner_text_object") => Some(&mut keymap.vim_operator.select_inner_text_object), + ("vim_operator", "select_around_text_object") => Some(&mut keymap.vim_operator.select_around_text_object), ("vim_operator", "cancel") => Some(&mut keymap.vim_operator.cancel), + ("vim_text_object", "word") => Some(&mut keymap.vim_text_object.word), + ("vim_text_object", "big_word") => Some(&mut keymap.vim_text_object.big_word), + ("vim_text_object", "parentheses") => Some(&mut keymap.vim_text_object.parentheses), + ("vim_text_object", "brackets") => Some(&mut keymap.vim_text_object.brackets), + ("vim_text_object", "braces") => Some(&mut keymap.vim_text_object.braces), + ("vim_text_object", "double_quote") => Some(&mut keymap.vim_text_object.double_quote), + ("vim_text_object", "single_quote") => Some(&mut keymap.vim_text_object.single_quote), + ("vim_text_object", "backtick") => Some(&mut keymap.vim_text_object.backtick), + ("vim_text_object", "cancel") => Some(&mut keymap.vim_text_object.cancel), ("pager", "scroll_up") => Some(&mut keymap.pager.scroll_up), ("pager", "scroll_down") => Some(&mut keymap.pager.scroll_down), ("pager", "page_up") => Some(&mut keymap.pager.page_up), @@ -331,6 +359,7 @@ pub(super) fn bindings_for_action<'a>( ("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()), ("global", "toggle_fast_mode") => Some(runtime_keymap.app.toggle_fast_mode.as_slice()), ("global", "toggle_raw_output") => Some(runtime_keymap.app.toggle_raw_output.as_slice()), + ("chat", "interrupt_turn") => Some(runtime_keymap.chat.interrupt_turn.as_slice()), ("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()), ("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()), ("chat", "edit_queued_message") => Some(runtime_keymap.chat.edit_queued_message.as_slice()), @@ -373,10 +402,12 @@ pub(super) fn bindings_for_action<'a>( ("vim_normal", "move_line_end") => Some(runtime_keymap.vim_normal.move_line_end.as_slice()), ("vim_normal", "delete_char") => Some(runtime_keymap.vim_normal.delete_char.as_slice()), ("vim_normal", "delete_to_line_end") => Some(runtime_keymap.vim_normal.delete_to_line_end.as_slice()), + ("vim_normal", "change_to_line_end") => Some(runtime_keymap.vim_normal.change_to_line_end.as_slice()), ("vim_normal", "yank_line") => Some(runtime_keymap.vim_normal.yank_line.as_slice()), ("vim_normal", "paste_after") => Some(runtime_keymap.vim_normal.paste_after.as_slice()), ("vim_normal", "start_delete_operator") => Some(runtime_keymap.vim_normal.start_delete_operator.as_slice()), ("vim_normal", "start_yank_operator") => Some(runtime_keymap.vim_normal.start_yank_operator.as_slice()), + ("vim_normal", "start_change_operator") => Some(runtime_keymap.vim_normal.start_change_operator.as_slice()), ("vim_normal", "cancel_operator") => Some(runtime_keymap.vim_normal.cancel_operator.as_slice()), ("vim_operator", "delete_line") => Some(runtime_keymap.vim_operator.delete_line.as_slice()), ("vim_operator", "yank_line") => Some(runtime_keymap.vim_operator.yank_line.as_slice()), @@ -389,7 +420,18 @@ pub(super) fn bindings_for_action<'a>( ("vim_operator", "motion_word_end") => Some(runtime_keymap.vim_operator.motion_word_end.as_slice()), ("vim_operator", "motion_line_start") => Some(runtime_keymap.vim_operator.motion_line_start.as_slice()), ("vim_operator", "motion_line_end") => Some(runtime_keymap.vim_operator.motion_line_end.as_slice()), + ("vim_operator", "select_inner_text_object") => Some(runtime_keymap.vim_operator.select_inner_text_object.as_slice()), + ("vim_operator", "select_around_text_object") => Some(runtime_keymap.vim_operator.select_around_text_object.as_slice()), ("vim_operator", "cancel") => Some(runtime_keymap.vim_operator.cancel.as_slice()), + ("vim_text_object", "word") => Some(runtime_keymap.vim_text_object.word.as_slice()), + ("vim_text_object", "big_word") => Some(runtime_keymap.vim_text_object.big_word.as_slice()), + ("vim_text_object", "parentheses") => Some(runtime_keymap.vim_text_object.parentheses.as_slice()), + ("vim_text_object", "brackets") => Some(runtime_keymap.vim_text_object.brackets.as_slice()), + ("vim_text_object", "braces") => Some(runtime_keymap.vim_text_object.braces.as_slice()), + ("vim_text_object", "double_quote") => Some(runtime_keymap.vim_text_object.double_quote.as_slice()), + ("vim_text_object", "single_quote") => Some(runtime_keymap.vim_text_object.single_quote.as_slice()), + ("vim_text_object", "backtick") => Some(runtime_keymap.vim_text_object.backtick.as_slice()), + ("vim_text_object", "cancel") => Some(runtime_keymap.vim_text_object.cancel.as_slice()), ("pager", "scroll_up") => Some(runtime_keymap.pager.scroll_up.as_slice()), ("pager", "scroll_down") => Some(runtime_keymap.pager.scroll_down.as_slice()), ("pager", "page_up") => Some(runtime_keymap.pager.page_up.as_slice()), diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs index b6c89d9585..883f9f898f 100644 --- a/codex-rs/tui/src/keymap_setup/picker.rs +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -60,6 +60,7 @@ struct KeymapContextTab { const KEYMAP_COMMON_ACTIONS: &[(&str, &str)] = &[ ("composer", "submit"), + ("chat", "interrupt_turn"), ("editor", "insert_newline"), ("composer", "queue"), ("global", "toggle_fast_mode"), @@ -104,7 +105,7 @@ const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[ id: "vim-shortcuts", label: "Vim", description: "Vim normal-mode and operator shortcuts.", - contexts: &["vim_normal", "vim_operator"], + contexts: &["vim_normal", "vim_operator", "vim_text_object"], }, KeymapContextTab { id: "navigation-shortcuts", diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 34065b7df8..a90fd7a362 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -177,6 +177,7 @@ mod status; mod status_indicator_widget; mod streaming; mod style; +mod terminal_hyperlinks; mod terminal_palette; mod terminal_probe; mod terminal_title; diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index 6d0daf89b2..31b8d09a76 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -25,6 +25,7 @@ use std::ops::Range; use std::path::Path; use crate::table_detect; +use crate::terminal_hyperlinks::HyperlinkLine; /// Render markdown source to styled ratatui lines and append them to `lines`. /// @@ -56,20 +57,22 @@ pub(crate) fn append_markdown_agent( width: Option, lines: &mut Vec>, ) { - append_markdown_agent_with_cwd(markdown_source, width, /*cwd*/ None, lines); + let normalized = unwrap_markdown_fences(markdown_source); + let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd( + &normalized, + width, + /*cwd*/ None, + ); + crate::render::line_utils::push_owned_lines(&rendered.lines, lines); } -/// Render an agent message while resolving local file links relative to `cwd`. -pub(crate) fn append_markdown_agent_with_cwd( +pub(crate) fn render_markdown_agent_with_links_and_cwd( markdown_source: &str, width: Option, cwd: Option<&Path>, - lines: &mut Vec>, -) { +) -> Vec { let normalized = unwrap_markdown_fences(markdown_source); - let rendered = - crate::markdown_render::render_markdown_text_with_width_and_cwd(&normalized, width, cwd); - crate::render::line_utils::push_owned_lines(&rendered.lines, lines); + crate::markdown_render::render_markdown_lines_with_width_and_cwd(&normalized, width, cwd) } /// Strip `` ```md ``/`` ```markdown `` fences that contain tables, emitting their content as bare @@ -397,8 +400,8 @@ mod tests { let mut out = Vec::new(); append_markdown_agent(src, /*width*/ None, &mut out); let rendered = lines_to_strings(&out); - assert!(rendered.iter().any(|line| line.contains("┌"))); - assert!(rendered.iter().any(|line| line.contains("│ 1 │ 2 │"))); + assert!(rendered.iter().any(|line| line.contains('━'))); + assert!(rendered.iter().any(|line| line.contains(" 1 2"))); } #[test] @@ -407,11 +410,11 @@ mod tests { let mut out = Vec::new(); append_markdown_agent(src, /*width*/ None, &mut out); let rendered = lines_to_strings(&out); - assert!(rendered.iter().any(|line| line.contains("┌"))); + assert!(rendered.iter().any(|line| line.contains('━'))); assert!( rendered .iter() - .any(|line| line.contains("│ Col A │ Col B │ Col C │")) + .any(|line| line.contains(" Col A Col B Col C")) ); assert!( !rendered @@ -426,8 +429,8 @@ mod tests { let mut out = Vec::new(); append_markdown_agent(src, /*width*/ None, &mut out); let rendered = lines_to_strings(&out); - assert!(rendered.iter().any(|line| line.contains("┌"))); - assert!(rendered.iter().any(|line| line.contains("│ A"))); + assert!(rendered.iter().any(|line| line.contains('━'))); + assert!(rendered.iter().any(|line| line.contains(" left right"))); assert!(!rendered.iter().any(|line| line.trim() == "A | B")); } @@ -437,7 +440,7 @@ mod tests { let mut out = Vec::new(); append_markdown_agent(src, /*width*/ None, &mut out); let rendered = lines_to_strings(&out); - assert!(rendered.iter().any(|line| line.contains("┌"))); + assert!(rendered.iter().any(|line| line.contains('━'))); assert!(!rendered.iter().any(|line| line.trim() == "| Only |")); } diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 75125a1ac1..9847c6dc3a 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -22,8 +22,9 @@ //! alignment count. //! 3. **Compute column widths** -- allocate widths with content-aware //! priority and iterative shrinking. -//! 4. **Render box grid** -- Unicode borders (`┌───┬───┐`) or fallback to pipe -//! format when the minimum cannot fit. +//! 4. **Choose presentation** -- render theme-accented row-separated columns +//! while values remain scannable, otherwise transpose body rows +//! into key/value records separated by muted rules. //! 5. **Append spillover** -- extracted spillover rows rendered as plain text //! after the table. //! @@ -33,12 +34,20 @@ //! or hashes), or Compact (short values such as counts and status labels). //! Token-heavy columns give up excess width before narrative columns so an //! oversized path does not collapse readable prose; compact values are -//! preserved last. When even 3-char-wide columns cannot fit, the table falls -//! back to pipe-delimited format. +//! preserved last. When compact values split, token-heavy values collapse into +//! unusably short chunks, expansive cells form tall narrow strips across enough +//! body rows, or even 3-char-wide columns cannot fit, body rows render as +//! key/value records. +use crate::render::highlight::foreground_style_for_scopes; use crate::render::highlight::highlight_code_to_lines; use crate::render::line_utils::line_to_static; -use crate::render::line_utils::push_owned_lines; +use crate::style::table_separator_style; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::annotate_web_urls_in_line; +use crate::terminal_hyperlinks::remap_wrapped_line; +use crate::terminal_hyperlinks::visible_lines; +use crate::terminal_hyperlinks::web_destination; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::word_wrap_line; @@ -63,9 +72,17 @@ use std::ops::Range; use std::path::Path; use std::path::PathBuf; use std::sync::LazyLock; +use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; use url::Url; +mod table_key_value; + +const TABLE_COLUMN_GAP: usize = 2; +const TABLE_CELL_PADDING: usize = 1; +const TABLE_HEADER_SEPARATOR_CHAR: char = '━'; +const TABLE_BODY_SEPARATOR_CHAR: char = '─'; + struct MarkdownStyles { h1: Style, h2: Style, @@ -128,7 +145,7 @@ impl IndentContext { /// `lines` are used for final rendering. #[derive(Clone, Debug, Default)] struct TableCell { - lines: Vec>, + lines: Vec, } // TableCell mutators inlined — called per-span during table event parsing. @@ -136,7 +153,7 @@ impl TableCell { #[inline] fn ensure_line(&mut self) { if self.lines.is_empty() { - self.lines.push(Line::default()); + self.lines.push(HyperlinkLine::new(Line::default())); } } @@ -144,13 +161,26 @@ impl TableCell { fn push_span(&mut self, span: Span<'static>) { self.ensure_line(); if let Some(line) = self.lines.last_mut() { - line.push_span(span); + line.line.push_span(span); + } + } + + fn push_annotated(&mut self, mut appended: HyperlinkLine) { + self.ensure_line(); + if let Some(line) = self.lines.last_mut() { + let shift = line.width(); + line.line.spans.append(&mut appended.line.spans); + line.hyperlinks + .extend(appended.hyperlinks.into_iter().map(|mut link| { + link.columns = link.columns.start + shift..link.columns.end + shift; + link + })); } } #[inline] fn hard_break(&mut self) { - self.lines.push(Line::default()); + self.lines.push(HyperlinkLine::new(Line::default())); } fn plain_text(&self) -> String { @@ -160,7 +190,7 @@ impl TableCell { if i > 0 { buf.push(' '); } - for span in &line.spans { + for span in &line.line.spans { let _ = write!(buf, "{}", span.content); } } @@ -207,14 +237,14 @@ impl TableState { /// Rendered table output split by wrapping behavior. /// -/// `table_lines` are either prewrapped grid rows (box rendering) or pipe -/// fallback rows that should still pass through normal wrapping. +/// `table_lines` are prewrapped aligned rows or key/value records, except +/// header-only tables may retain pipe fallback rows for normal wrapping. /// `spillover_lines` are prose rows extracted from parser artifacts and should /// be routed through normal wrapping. struct RenderedTableLines { - table_lines: Vec>, + table_lines: Vec, table_lines_prewrapped: bool, - spillover_lines: Vec>, + spillover_lines: Vec, } /// Classification of a table column for width-allocation priority. @@ -259,11 +289,11 @@ pub fn render_markdown_text(input: &str) -> Text<'static> { /// Render markdown constrained to a known terminal width. /// -/// The renderer preserves table structure when possible and falls back to -/// pipe-table output when a box table cannot fit the available width. Passing -/// `None` keeps intrinsic line widths and disables width-driven wrapping in the -/// markdown writer. Local file links render relative to the current process -/// working directory. +/// The renderer preserves columnar table structure while values remain +/// scannable and falls back to key/value records when body rows cannot fit +/// readably. Passing `None` keeps intrinsic line widths and disables +/// width-driven wrapping in the markdown writer. Local file links render +/// relative to the current process working directory. pub(crate) fn render_markdown_text_with_width(input: &str, width: Option) -> Text<'static> { let cwd = std::env::current_dir().ok(); render_markdown_text_with_width_and_cwd(input, width, cwd.as_deref()) @@ -279,6 +309,16 @@ pub(crate) fn render_markdown_text_with_width_and_cwd( width: Option, cwd: Option<&Path>, ) -> Text<'static> { + Text::from(visible_lines(render_markdown_lines_with_width_and_cwd( + input, width, cwd, + ))) +} + +pub(crate) fn render_markdown_lines_with_width_and_cwd( + input: &str, + width: Option, + cwd: Option<&Path>, +) -> Vec { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_TABLES); @@ -330,7 +370,7 @@ where { input: &'a str, iter: I, - text: Text<'static>, + text: Vec, styles: MarkdownStyles, inline_styles: Vec